diff --git a/frontend/src/lib/integrations/integrationsLogic.test.ts b/frontend/src/lib/integrations/integrationsLogic.test.ts index 11507a2825b9..7519ec77041c 100644 --- a/frontend/src/lib/integrations/integrationsLogic.test.ts +++ b/frontend/src/lib/integrations/integrationsLogic.test.ts @@ -254,12 +254,25 @@ describe('integrationsLogic', () => { ensureStatus = 200 await expectLogic(logic, () => logic.actions.ensureSandboxEmailSender()) - .toDispatchActions(['provisionSandboxEmailSenderSuccess', 'loadIntegrationsSuccess']) + .toDispatchActions(['loadIntegrationsSuccess', 'provisionSandboxEmailSenderSuccess']) .toMatchValues({ sandboxEmailSender: expect.objectContaining({ id: sandboxSender.id }) }) expect(ensureCalls).toBe(2) }) + it('reports provisioning failure when the refreshed sender list cannot load', async () => { + useMocks({ + get: { + '/api/projects/:team_id/integrations/': () => [500, { detail: 'Sender list unavailable' }], + }, + }) + enableFlag() + await expectLogic(logic, () => logic.actions.ensureSandboxEmailSender()).toFinishAllListeners() + + expect(logic.values.sandboxEmailSenderProvisionResult).toBe('failed') + expect(ensureCalls).toBe(1) + }) + it('provisions once the flag arrives after a surface asked for the sandbox sender', async () => { await expectLogic(logic, () => { logic.actions.ensureSandboxEmailSender() diff --git a/frontend/src/lib/integrations/integrationsLogic.ts b/frontend/src/lib/integrations/integrationsLogic.ts index 0480cb243340..a0a247a47e6a 100644 --- a/frontend/src/lib/integrations/integrationsLogic.ts +++ b/frontend/src/lib/integrations/integrationsLogic.ts @@ -1105,7 +1105,7 @@ export const integrationsLogic = kea([ }, ], }), - loaders(({ actions, values, cache }) => ({ + loaders(({ actions, asyncActions, values, cache }) => ({ integrations: [ null as IntegrationType[] | null, { @@ -1233,14 +1233,36 @@ export const integrationsLogic = kea([ { provisionSandboxEmailSender: async (): Promise => { const projectId = values.currentProjectId - const result = await requestSandboxEmailSender(String(projectId)) + if (!cache.sandboxProvisionRequest || cache.sandboxProvisionRequest.projectId !== projectId) { + const promise = requestSandboxEmailSender(String(projectId)) + .then(async (result): Promise>> => { + if (values.currentProjectId === projectId && result.result === 'provisioned') { + await asyncActions.loadIntegrations() + if ( + values.currentProjectId === projectId && + !values.integrations?.some(isSandboxEmailSender) + ) { + return { + result: 'failed', + error: 'Could not load the sandbox sender after setup. Try again.', + } + } + } + return result + }) + .finally(() => { + if (cache.sandboxProvisionRequest?.promise === promise) { + cache.sandboxProvisionRequest = undefined + } + }) + cache.sandboxProvisionRequest = { projectId, promise } + } + const result: Awaited> = + await cache.sandboxProvisionRequest.promise if (values.currentProjectId !== projectId) { return values.sandboxEmailSenderProvisionResult } actions.setSandboxEmailSenderProvisionError(result.error) - if (result.result === 'provisioned') { - actions.loadIntegrations() - } return result.result }, }, diff --git a/products/workflows/frontend/Channels/defaultEmailSender.ts b/products/workflows/frontend/Channels/defaultEmailSender.ts index 063d45406654..65962bb6c2e3 100644 --- a/products/workflows/frontend/Channels/defaultEmailSender.ts +++ b/products/workflows/frontend/Channels/defaultEmailSender.ts @@ -2,7 +2,7 @@ import { isSandboxEmailSender } from 'lib/integrations/utils' import { IntegrationType } from '~/types' -export type DefaultEmailSenderReason = 'project_default' | 'only_verified_sender' +export type DefaultEmailSenderReason = 'project_default' | 'only_verified_sender' | 'sandbox_sender' export interface DefaultEmailSender { integrationId: number diff --git a/products/workflows/frontend/Workflows/WorkflowActions.tsx b/products/workflows/frontend/Workflows/WorkflowActions.tsx index 4816f4e88e66..0670024bab26 100644 --- a/products/workflows/frontend/Workflows/WorkflowActions.tsx +++ b/products/workflows/frontend/Workflows/WorkflowActions.tsx @@ -32,6 +32,7 @@ export function WorkflowActions(props: WorkflowSceneLogicProps): JSX.Element { discardDisabledReason, showDraftActions, statusToggleDisabledReason, + saveBlockedReason, } = useValues(workflowLogic) const { saveWorkflowPartial, submitWorkflow, discardChanges, publishDraft, discardDraft } = useActions(workflowLogic) @@ -44,6 +45,13 @@ export function WorkflowActions(props: WorkflowSceneLogicProps): JSX.Element { const isCreatedFromTemplate = props.id === 'new' && !!templateId const isCreatedFromLink = props.id === 'new' && !!parseWorkflowTriggerPrefill(searchParams[TRIGGER_PREFILL_PARAM]) const isManualWorkflow = ['manual', 'batch'].includes(workflow?.trigger?.type || '') + const saveDisabledReason = saveBlockedReason + ? saveBlockedReason + : workflowHasErrors + ? 'Some fields still need work' + : isCreatedFromTemplate || isCreatedFromLink || hasUnsavedChanges + ? undefined + : 'No changes to save' const todayRailNav = useFeatureFlag('TODAY_RAIL_NAV') const [displayStatus, setDisplayStatus] = useState(workflow?.status) const [isTransitioning, setIsTransitioning] = useState(false) @@ -135,15 +143,7 @@ export function WorkflowActions(props: WorkflowSceneLogicProps): JSX.Element { editTemplateId={editTemplateId} isWorkflowSubmitting={isWorkflowSubmitting} draftActionPending={draftActionPending} - saveDisabledReason={ - workflowHasErrors - ? 'Some fields still need work' - : isCreatedFromTemplate || isCreatedFromLink - ? undefined - : hasUnsavedChanges - ? undefined - : 'No changes to save' - } + saveDisabledReason={saveDisabledReason} publishDisabledReason={publishDisabledReason} discardDisabledReason={discardDisabledReason} saveLabel={ @@ -199,15 +199,7 @@ export function WorkflowActions(props: WorkflowSceneLogicProps): JSX.Element { form="workflow" onClick={submitWorkflow} loading={isWorkflowSubmitting} - disabledReason={ - workflowHasErrors - ? 'Some fields still need work' - : isCreatedFromTemplate || isCreatedFromLink - ? undefined - : hasUnsavedChanges - ? undefined - : 'No changes to save' - } + disabledReason={saveDisabledReason} > {props.id === 'new' ? 'Create as draft' diff --git a/products/workflows/frontend/Workflows/hogflows/editor/tile-canvas/tileCanvasLogic.test.ts b/products/workflows/frontend/Workflows/hogflows/editor/tile-canvas/tileCanvasLogic.test.ts index ee9520329003..29f22ba2085e 100644 --- a/products/workflows/frontend/Workflows/hogflows/editor/tile-canvas/tileCanvasLogic.test.ts +++ b/products/workflows/frontend/Workflows/hogflows/editor/tile-canvas/tileCanvasLogic.test.ts @@ -1,6 +1,8 @@ import { expectLogic } from 'kea-test-utils' import posthog from 'posthog-js' +import { lemonToast } from '@posthog/lemon-ui' + import { loadAppMetricsTotals } from 'lib/components/AppMetrics/appMetricsLogic' import { useMocks } from '~/mocks/jest' @@ -216,7 +218,7 @@ describe('tileCanvasLogic', () => { logic.mount() }) - it.each([ + const sources = [ { source: 'palette', add: (): void => { @@ -237,7 +239,9 @@ describe('tileCanvasLogic', () => { source: 'quick add', add: (): void => logic.actions.addStepAt(delayStep, logic.values.workflow.edges[0]), }, - ])('counts a step added from the $source', ({ source, add }) => { + ] + + it.each(sources)('counts a step added from the $source', ({ source, add }) => { const capture = jest.spyOn(posthog, 'capture') add() @@ -245,6 +249,20 @@ describe('tileCanvasLogic', () => { expect(capture).toHaveBeenCalledWith('workflows tile canvas step added', { source, step_type: 'delay' }) }) + it.each(sources)('asks to wait instead of adding from the $source while an email is added', ({ add }) => { + hogFlowEditorLogic().actions.setEmailInsertionPending(true) + const capture = jest.spyOn(posthog, 'capture') + const toast = jest.spyOn(lemonToast, 'info').mockImplementation() + + add() + + expect(capture).not.toHaveBeenCalledWith('workflows tile canvas step added', expect.anything()) + expect(toast).toHaveBeenCalledWith('An email step is still being added. Try again in a moment.') + expect(logic.values.nodeToBeAdded).toBeNull() + capture.mockRestore() + toast.mockRestore() + }) + it.each(['startMovingNode', 'startCopyingNode'] as const)( 'drops a pending %s when someone starts adding a step', async (startPending) => { diff --git a/products/workflows/frontend/Workflows/hogflows/editor/tile-canvas/tileCanvasLogic.ts b/products/workflows/frontend/Workflows/hogflows/editor/tile-canvas/tileCanvasLogic.ts index 63f36aba7e39..ccf397ebfb57 100644 --- a/products/workflows/frontend/Workflows/hogflows/editor/tile-canvas/tileCanvasLogic.ts +++ b/products/workflows/frontend/Workflows/hogflows/editor/tile-canvas/tileCanvasLogic.ts @@ -17,6 +17,8 @@ import { subscriptions } from 'kea-subscriptions' import posthog from 'posthog-js' import type { DragEvent } from 'react' +import { lemonToast } from '@posthog/lemon-ui' + import { loadAppMetricsTotals } from 'lib/components/AppMetrics/appMetricsLogic' import { FeatureFlagsSet, featureFlagLogic } from 'lib/logic/featureFlagLogic' import { objectsEqual } from 'lib/utils/objects' @@ -25,7 +27,7 @@ import { teamLogic } from 'scenes/teamLogic' import { sceneLayoutLogic } from '~/layout/scenes/sceneLayoutLogic' import { HogFunctionTemplateType, TeamPublicType, TeamType } from '~/types' -import { WorkflowLogicProps, workflowLogic } from '../../../workflowLogic' +import { EMAIL_INSERTION_PENDING_REASON, WorkflowLogicProps, workflowLogic } from '../../../workflowLogic' import { getEdgeId } from '../../edgeId' import { actionMetricsFromTotals, @@ -155,6 +157,7 @@ function isTypedOnCanvas(event: KeyboardEvent): boolean { export interface tileCanvasLogicValues { featureFlags: FeatureFlagsSet // featureFlagLogic dropzoneNodes: DropzoneNode[] // hogFlowEditorLogic + emailInsertionPending: boolean // hogFlowEditorLogic isCopyingNode: boolean // hogFlowEditorLogic isMovingNode: boolean // hogFlowEditorLogic mode: HogFlowEditorMode // hogFlowEditorLogic @@ -446,6 +449,7 @@ export const tileCanvasLogic = kea([ 'selectedNodeCanBeDeleted', 'selectedNodeCanBeCopiedOrMoved', 'mode', + 'emailInsertionPending', ], featureFlagLogic, ['featureFlags'], @@ -722,6 +726,12 @@ export const tileCanvasLogic = kea([ actions.copyNodeToHighlightedDropzone() } } + const emailInsertionBlocks = (): boolean => { + if (values.emailInsertionPending) { + lemonToast.info(EMAIL_INSERTION_PENDING_REASON) + } + return values.emailInsertionPending + } const placeNewStep = (edge: HogFlowEdge, step: CreateActionType | HogFlowActionNode): void => { actions.onDrop(undefined, edge) captureStepAdded(values.placingSource, 'data' in step ? step.data.type : step.type) @@ -781,16 +791,25 @@ export const tileCanvasLogic = kea([ }, revealStep: ({ actionId }) => revealContainingPaths(actionId), startPlacing: ({ step }) => { + if (emailInsertionBlocks()) { + return + } dropPendingMoveOrCopy() actions.setNodeToBeAdded(step) }, addStepAt: ({ step, edge }) => { + if (emailInsertionBlocks()) { + return + } dropPendingMoveOrCopy() actions.setNodeToBeAdded(step) actions.onDrop(undefined, edge) captureStepAdded('quick add', step.type) }, placeAt: ({ edge }) => { + if (emailInsertionBlocks()) { + return + } if (values.isMovingNode || values.isCopyingNode) { placeExistingStep(edge) } else if (values.nodeToBeAdded) { diff --git a/products/workflows/frontend/Workflows/hogflows/hogFlowEditorLogic.test.ts b/products/workflows/frontend/Workflows/hogflows/hogFlowEditorLogic.test.ts index 1e5d9ce8a1df..e42e9ac861c7 100644 --- a/products/workflows/frontend/Workflows/hogflows/hogFlowEditorLogic.test.ts +++ b/products/workflows/frontend/Workflows/hogflows/hogFlowEditorLogic.test.ts @@ -289,7 +289,7 @@ describe('hogFlowEditorLogic', () => { }, } as HogFlowAction, }, - ])('duplicates $case below itself', ({ step }) => { + ])('duplicates $case below itself', async ({ step }) => { teamLogic.actions.loadCurrentTeamSuccess({ ...MOCK_DEFAULT_TEAM, workflows_config: { @@ -303,7 +303,7 @@ describe('hogFlowEditorLogic', () => { edges: [edge(TRIGGER_NODE_ID, step.id, 'continue'), edge(step.id, EXIT_NODE_ID, 'continue')], }) - logic.actions.duplicateNodeBelow(step.id) + await expectLogic(logic, () => logic.actions.duplicateNodeBelow(step.id)).toFinishAllListeners() const duplicatedAction = logic.values.workflow.actions.find( (action) => action.id !== step.id && action.type === step.type diff --git a/products/workflows/frontend/Workflows/hogflows/hogFlowEditorLogic.tsx b/products/workflows/frontend/Workflows/hogflows/hogFlowEditorLogic.tsx index c66aa6e1c3bc..d845401ef6de 100644 --- a/products/workflows/frontend/Workflows/hogflows/hogFlowEditorLogic.tsx +++ b/products/workflows/frontend/Workflows/hogflows/hogFlowEditorLogic.tsx @@ -41,7 +41,7 @@ import type { TeamType, UserBasicType, } from '../../../../../frontend/src/types' -import { resolveDefaultEmailSender } from '../../Channels/defaultEmailSender' +import { firstRunSenderLogic } from '../../firstRun/firstRunSenderLogic' import { optOutCategoriesLogic } from '../../OptOuts/optOutCategoriesLogic' import type { MessageCategory } from '../../OptOuts/optOutCategoriesLogic' import { EXIT_NODE_ID, TRIGGER_NODE_ID, WorkflowLogicProps, workflowLogic } from '../workflowLogic' @@ -267,6 +267,7 @@ export interface hogFlowEditorLogicValues { categoriesLoading: boolean // optOutCategoriesLogic currentTeam: TeamPublicType | TeamType | null // teamLogic edgesByActionId: Record // workflowLogic + emailInsertionPending: boolean // workflowLogic hogFunctionTemplatesById: Record // workflowLogic workflow: HogFlow // workflowLogic actionMetricsById: Record | null @@ -2074,6 +2075,9 @@ export interface hogFlowEditorLogicActions { } payload?: any } // workflowLogic + setEmailInsertionPending: (pending: boolean) => { + pending: boolean + } // workflowLogic setWorkflowAction: ( actionId: string, action: HogFlowAction @@ -2299,7 +2303,7 @@ export const hogFlowEditorLogic = kea([ connect(() => ({ values: [ workflowLogic, - ['workflow', 'edgesByActionId', 'hogFunctionTemplatesById'], + ['workflow', 'edgesByActionId', 'hogFunctionTemplatesById', 'emailInsertionPending'], optOutCategoriesLogic(), ['categories', 'categoriesLoading'], integrationsLogic, @@ -2311,7 +2315,13 @@ export const hogFlowEditorLogic = kea([ ], actions: [ workflowLogic, - ['setWorkflowInfo', 'setWorkflowAction', 'setWorkflowActionEdges', 'loadWorkflowSuccess'], + [ + 'setWorkflowInfo', + 'setWorkflowAction', + 'setWorkflowActionEdges', + 'loadWorkflowSuccess', + 'setEmailInsertionPending', + ], optOutCategoriesLogic(), ['loadCategories'], ], @@ -2589,7 +2599,7 @@ export const hogFlowEditorLogic = kea([ }, ], })), - listeners(({ values, actions }) => { + listeners(({ values, actions, props, cache }) => { let animationTimeout: ReturnType | null = null return { onEdgesChange: ({ edges }) => { @@ -2856,8 +2866,12 @@ export const hogFlowEditorLogic = kea([ event.dataTransfer.dropEffect = 'move' }, - onDrop: ({ event, targetEdge, joinEdges }) => { + onDrop: async ({ event, targetEdge, joinEdges }) => { event?.preventDefault() + if (values.emailInsertionPending) { + return + } + const nodeToBeAdded = values.nodeToBeAdded const dropzoneNode = values.dropzoneNodes.find((x) => x.id === values.highlightedDropzoneNodeId) if (values.nodeToBeAdded && (dropzoneNode || targetEdge)) { @@ -2914,16 +2928,75 @@ export const hogFlowEditorLogic = kea([ } } - if (!isHogFlowActionNode && 'template_id' in config && config.template_id === 'template-email') { - const emailInput = config.inputs?.email - const team = values.currentTeam - const sender = resolveDefaultEmailSender( - values.integrations, - team && 'workflows_config' in team - ? team.workflows_config?.default_email_integration_id - : null - ) - if (sender && emailInput?.value && !emailInput.value.from?.integrationId) { + if ( + !isHogFlowActionNode && + 'template_id' in config && + config.template_id === 'template-email' && + !config.inputs?.email?.value?.from?.integrationId && + !config.inputs?.email?.value?.from?.email && + !config.inputs?.email?.value?.from?.name + ) { + let emailInput = config.inputs?.email + const workflowId = values.workflow.id + const teamId = values.currentTeam?.id + const mountGeneration = cache.mountGeneration + const insertionIsCurrent = (): boolean => + cache.mountGeneration === mountGeneration && + values.workflow.id === workflowId && + values.currentTeam?.id === teamId + const abandonInsertion = (): void => { + if (cache.mountGeneration !== mountGeneration) { + return + } + lemonToast.error("The email step wasn't added because the workflow changed. Add it again.") + actions.setNodeToBeAdded(null) + actions.hideDropzones() + } + actions.setEmailInsertionPending(true) + let sender + let unmount: (() => void) | undefined + try { + if (!emailInput && !values.hogFunctionTemplatesById['template-email']) { + await workflowLogic(props).asyncActions.loadHogFunctionTemplatesById() + if (!insertionIsCurrent()) { + abandonInsertion() + return + } + const template = values.hogFunctionTemplatesById['template-email'] + if (template) { + config = { + ...config, + inputs: { ...templateToConfiguration(template).inputs, ...config.inputs }, + } + emailInput = config.inputs?.email + } + } + if ( + !emailInput?.value?.from?.integrationId && + !emailInput?.value?.from?.email && + !emailInput?.value?.from?.name + ) { + const senderLogic = firstRunSenderLogic({ + allowSandbox: + partialNewAction.type === 'function_email' && + values.workflow.origin_product !== 'broadcasts' && + !emailInput?.value?.replyTo, + }) + unmount = senderLogic.mount() + await senderLogic.asyncActions.resolveFirstRunSender() + sender = senderLogic.values.resolvedFirstRunSender + } + } finally { + unmount?.() + if (cache.mountGeneration === mountGeneration) { + actions.setEmailInsertionPending(false) + } + } + if (!insertionIsCurrent()) { + abandonInsertion() + return + } + if (sender) { config = { ...config, inputs: { @@ -2931,8 +3004,8 @@ export const hogFlowEditorLogic = kea([ email: { ...emailInput, value: { - ...emailInput.value, - from: { ...emailInput.value.from, integrationId: sender.integrationId }, + ...emailInput?.value, + from: { ...emailInput?.value?.from, integrationId: sender.integrationId }, }, }, }, @@ -2944,7 +3017,7 @@ export const hogFlowEditorLogic = kea([ const newAction = { id: isHogFlowActionNode - ? (values.nodeToBeAdded as HogFlowActionNode).id + ? (nodeToBeAdded as HogFlowActionNode).id : `action_${partialNewAction.type}_${uuid()}`, type: partialNewAction.type, name: partialNewAction.name, @@ -2986,8 +3059,10 @@ export const hogFlowEditorLogic = kea([ edgesToBeReplaced ) - if (!newEdges) { - throw new Error('Edge to be replaced not found') + if (!newEdges || (joinEdges && edgesToBeReplaced.length !== joinEdges.length)) { + lemonToast.error('The insertion point changed. Choose where to insert the step again.') + actions.setNodeToBeAdded(null) + return } // Auto-create workflow variables if the action has a default output_variable @@ -3196,6 +3271,9 @@ export const hogFlowEditorLogic = kea([ }) }, duplicateNodeBelow: ({ actionId }) => { + if (values.emailInsertionPending) { + return + } const action = values.workflow.actions.find((action) => action.id === actionId) const targetEdge = values.workflow.edges.find((edge) => edge.from === actionId) @@ -3281,8 +3359,9 @@ export const hogFlowEditorLogic = kea([ [urls.workflow(':id', ':tab')]: reactToTabChange, } }), - events(({ actions, values }) => ({ + events(({ actions, values, cache }) => ({ afterMount: () => { + cache.mountGeneration = uuid() if (values.editorLayout === 'simple' && !isWorkflowTreeComplete(values.workflow)) { actions.setEditorLayout('advanced') } @@ -3305,6 +3384,8 @@ export const hogFlowEditorLogic = kea([ ;(actions as any)._keydownHandler = handleKeyDown }, beforeUnmount: () => { + cache.mountGeneration = null + actions.setEmailInsertionPending(false) const handler = (actions as any)._keydownHandler if (handler) { document.removeEventListener('keydown', handler) diff --git a/products/workflows/frontend/Workflows/hogflows/panel/HogFlowEditorPanelBuild.tsx b/products/workflows/frontend/Workflows/hogflows/panel/HogFlowEditorPanelBuild.tsx index 6d02f59f0fe5..3daf9ba92c0e 100644 --- a/products/workflows/frontend/Workflows/hogflows/panel/HogFlowEditorPanelBuild.tsx +++ b/products/workflows/frontend/Workflows/hogflows/panel/HogFlowEditorPanelBuild.tsx @@ -29,9 +29,14 @@ function HogFlowEditorToolbarNode({ children?: React.ReactNode }): JSX.Element | null { const { hideDropzones, setNodeToBeAdded, showDropzones } = useActions(hogFlowEditorLogic) + const { emailInsertionPending } = useValues(hogFlowEditorLogic) const dragPreviewRef = useRef(null) const onDragStart = (event: React.DragEvent): void => { + if (emailInsertionPending) { + event.preventDefault() + return + } setNodeToBeAdded(action) showDropzones() event.dataTransfer.setData('application/reactflow', action.type) @@ -53,11 +58,13 @@ function HogFlowEditorToolbarNode({ return ( <>
{step.icon}} sideIcon={onActionSelect ? undefined : } fullWidth diff --git a/products/workflows/frontend/Workflows/hogflows/steps/components/StepView.tsx b/products/workflows/frontend/Workflows/hogflows/steps/components/StepView.tsx index f35a39bbd1dd..6f991d06afde 100644 --- a/products/workflows/frontend/Workflows/hogflows/steps/components/StepView.tsx +++ b/products/workflows/frontend/Workflows/hogflows/steps/components/StepView.tsx @@ -26,6 +26,7 @@ export function StepView({ action }: { action: HogFlowAction }): JSX.Element { nodesById, selectedNodeCanBeDeleted, selectedNodeCanBeCopiedOrMoved, + emailInsertionPending, animatingEdgePair, workflow, isZoomedOutFar, @@ -208,6 +209,9 @@ export function StepView({ action }: { action: HogFlowAction }): JSX.Element { selectedNodeCanBeCopiedOrMoved ? { label: 'Copy', + disabledReason: emailInsertionPending + ? 'Preparing the email sender…' + : undefined, icon: , status: 'default', onClick: () => startCopyingNode(node), @@ -216,6 +220,9 @@ export function StepView({ action }: { action: HogFlowAction }): JSX.Element { selectedNodeCanBeCopiedOrMoved ? { label: 'Move', + disabledReason: emailInsertionPending + ? 'Preparing the email sender…' + : undefined, icon: , status: 'default', onClick: () => startMovingNode(node), diff --git a/products/workflows/frontend/Workflows/hogflows/tree/HogFlowTreeDropzone.tsx b/products/workflows/frontend/Workflows/hogflows/tree/HogFlowTreeDropzone.tsx index e2535300671b..16903160eb83 100644 --- a/products/workflows/frontend/Workflows/hogflows/tree/HogFlowTreeDropzone.tsx +++ b/products/workflows/frontend/Workflows/hogflows/tree/HogFlowTreeDropzone.tsx @@ -37,7 +37,7 @@ export function HogFlowTreeDropzone({ insertionLabel?: string alwaysVisible?: boolean }): JSX.Element { - const { workflow } = useValues(hogFlowEditorLogic) + const { workflow, emailInsertionPending } = useValues(hogFlowEditorLogic) const { moveNodeToEdge, onDragOver, @@ -55,6 +55,10 @@ export function HogFlowTreeDropzone({ onDragOver(event) } const handleDrop = (event: DragEvent): void => { + event.preventDefault() + if (emailInsertionPending) { + return + } setHighlighted(false) const draggedActionId = draggedActionIdRef.current if (draggedActionId) { @@ -85,6 +89,9 @@ export function HogFlowTreeDropzone({ } } const handleInsertAction = (action: CreateActionType): void => { + if (emailInsertionPending) { + return + } setPickerOpen(false) setNodeToBeAdded(action) @@ -102,7 +109,7 @@ export function HogFlowTreeDropzone({ alwaysVisible ? 'min-h-8' : compact ? 'h-2' : 'h-4' )} data-workflow-tree-dropzone-candidate - data-workflow-tree-dropzone-disabled={isAdjacentToDraggedAction || undefined} + data-workflow-tree-dropzone-disabled={isAdjacentToDraggedAction || emailInsertionPending || undefined} >
duplicateNodeBelow(action.id)} data-attr="workflow-tree-duplicate-step" > diff --git a/products/workflows/frontend/Workflows/workflowEmailPrefill.test.ts b/products/workflows/frontend/Workflows/workflowEmailPrefill.test.ts index d67b71e79f1c..d019be45b674 100644 --- a/products/workflows/frontend/Workflows/workflowEmailPrefill.test.ts +++ b/products/workflows/frontend/Workflows/workflowEmailPrefill.test.ts @@ -1,5 +1,5 @@ import { draftMessage } from '../MessageAudience/messageDrafts' -import { prefilledWorkflow, withEmailSender } from './workflowEmailPrefill' +import { prefilledWorkflow } from './workflowEmailPrefill' import { NEW_WORKFLOW } from './workflowLogic' import type { WorkflowTriggerConfig } from './workflowTriggerPrefill' @@ -31,14 +31,4 @@ describe('workflowEmailPrefill', () => { }) expect(workflow.trigger_masking).toEqual(masking) }) - - it('keeps a sender the email step already has and fills the ones without', () => { - const workflow = prefilledWorkflow(NEW_WORKFLOW, EVENT_TRIGGER, draftMessage({ kind: 'issue_hit' })) - const filled = withEmailSender(workflow, 5) - const refilled = withEmailSender(filled.workflow, 9) - - expect(filled.filled).toBe(true) - expect((filled.workflow.actions[1].config as any).inputs.email.value.from).toEqual({ integrationId: 5 }) - expect(refilled).toEqual({ workflow: filled.workflow, filled: false }) - }) }) diff --git a/products/workflows/frontend/Workflows/workflowEmailPrefill.ts b/products/workflows/frontend/Workflows/workflowEmailPrefill.ts index 1ae94083ed01..d5a4a9b12a6b 100644 --- a/products/workflows/frontend/Workflows/workflowEmailPrefill.ts +++ b/products/workflows/frontend/Workflows/workflowEmailPrefill.ts @@ -92,26 +92,3 @@ export function withPrefilledEmail(workflow: HogFlow, email: MessageDraft): HogF }), } } - -export function withEmailSender(workflow: HogFlow, integrationId: number): { workflow: HogFlow; filled: boolean } { - let filled = false - const actions = workflow.actions.map((action) => { - const config = action.config as Record - const value = config?.template_id === 'template-email' ? config.inputs?.email?.value : null - if (!value || value.from?.integrationId) { - return action - } - filled = true - return { - ...action, - config: { - ...config, - inputs: { - ...config.inputs, - email: { ...config.inputs.email, value: { ...value, from: { ...value.from, integrationId } } }, - }, - }, - } as HogFlowAction - }) - return { workflow: filled ? { ...workflow, actions } : workflow, filled } -} diff --git a/products/workflows/frontend/Workflows/workflowLogic.firstRunSender.test.ts b/products/workflows/frontend/Workflows/workflowLogic.firstRunSender.test.ts new file mode 100644 index 000000000000..dee32bc4b87a --- /dev/null +++ b/products/workflows/frontend/Workflows/workflowLogic.firstRunSender.test.ts @@ -0,0 +1,504 @@ +import { expectLogic } from 'kea-test-utils' +import posthog from 'posthog-js' + +import { lemonToast } from '@posthog/lemon-ui' + +import { FEATURE_FLAGS } from 'lib/constants' +import { integrationsLogic } from 'lib/integrations/integrationsLogic' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' + +import { useMocks } from '~/mocks/jest' +import { initKeaTests } from '~/test/init' +import { IntegrationType } from '~/types' + +import { draftMessage } from '../MessageAudience/messageDrafts' +import { hogFlowEditorLogic } from './hogflows/hogFlowEditorLogic' +import { HogFlowAction } from './hogflows/types' +import { workflowStepSections } from './hogflows/workflowStepCatalog' +import { NEW_WORKFLOW, workflowLogic } from './workflowLogic' + +const PALETTE_EMAIL_STEP = workflowStepSections({ featureFlags: {}, currentTeam: null, isRowScopedTrigger: false }) + .flatMap((section) => section.steps) + .find((step) => step.type === 'function_email')! + +const SANDBOX: Partial = { + id: 7, + kind: 'email', + display_name: 'Example via PostHog ', + config: { provider: 'sandbox', verified: true, email: 'sandbox@example.com' }, +} +const emailAction = (integrationId?: number): HogFlowAction => ({ + id: 'email', + type: 'function_email', + name: 'Send email', + description: '', + config: { + template_id: 'template-email', + inputs: { + email: { + value: { + from: integrationId ? { integrationId } : {}, + to: { email: 'member@example.com' }, + subject: 'Welcome', + html: '

Your account is ready.

', + }, + templating: 'liquid', + }, + }, + }, +}) + +describe('workflow first-run sender', () => { + let ensureCalls: number + let integrations: Partial[] + let action: HogFlowAction + beforeEach(() => { + integrations = [] + ensureCalls = 0 + action = emailAction() + useMocks({ + get: { + '/api/projects/:team_id/integrations/': () => [200, { results: integrations }], + '/api/projects/:team_id/hog_flow_templates/:id/': () => [ + 200, + { + ...NEW_WORKFLOW, + actions: [NEW_WORKFLOW.actions[0], action, NEW_WORKFLOW.actions[1]], + }, + ], + '/api/projects/:team_id/hog_function_templates/': { results: [], count: 0 }, + }, + post: { + '/api/projects/:team_id/integrations/email_sandbox_sender/': () => { + ensureCalls++ + integrations = [...integrations, SANDBOX] + return [200, SANDBOX] + }, + }, + }) + initKeaTests() + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + }) + + it.each([ + ['function_email', false], + ['function', false], + ['function', true], + ] as const)('loads a %s template with an eligible first-run sender (own sender=%s)', async (type, ownSender) => { + action = { ...emailAction(), type } as HogFlowAction + if (ownSender) { + integrations = [ + { id: 5, kind: 'email', config: { verified: true, provider: 'ses', email: 'own@example.com' } }, + ] + } + const logic = workflowLogic({ id: 'new', templateId: 'first-run-example' }) + logic.mount() + await expectLogic(logic).toDispatchActions(['loadWorkflowSuccess']) + const email = logic.values.workflow.actions.find((step) => step.id === 'email') + expect(email).toMatchObject({ + type, + config: { + inputs: { + email: { + value: { + from: ownSender + ? { integrationId: 5 } + : type === 'function_email' + ? { integrationId: SANDBOX.id } + : {}, + subject: 'Welcome', + html: '

Your account is ready.

', + }, + templating: 'liquid', + }, + }, + }, + }) + const from = + email?.type === 'function_email' || email?.type === 'function' + ? email.config.inputs?.email?.value?.from + : undefined + expect(from?.integrationId).toBe(ownSender ? 5 : type === 'function_email' ? SANDBOX.id : undefined) + expect(ensureCalls).toBe(!ownSender && type === 'function_email' ? 1 : 0) + logic.unmount() + }) + + it.each([ + ['a template', { templateId: 'first-run-example' }], + [ + 'a trigger prefill', + { + triggerPrefill: JSON.stringify({ + type: 'event', + filters: { events: [{ id: '$pageview', name: '$pageview', type: 'events' }] }, + }), + emailPrefill: JSON.stringify(draftMessage({ kind: 'issue_hit' })), + }, + ], + ])('preselects the first-run sender once for a workflow from %s', async (_source, prefillProps) => { + const capture = jest.spyOn(posthog, 'capture').mockImplementation() + const logic = workflowLogic({ id: 'new', ...prefillProps }) + logic.mount() + await expectLogic(logic).toDispatchActions(['loadWorkflowSuccess']) + const email = logic.values.workflow.actions.find((step) => step.type === 'function_email') + expect(email?.type === 'function_email' && email.config.inputs?.email?.value?.from).toEqual({ + integrationId: SANDBOX.id, + }) + expect(capture.mock.calls.filter(([event]) => event === 'email sender preselected')).toEqual([ + ['email sender preselected', { surface: 'workflow', reason: 'sandbox_sender' }], + ]) + capture.mockRestore() + logic.unmount() + }) + + it('keeps the sandbox sender off a duplicated generic function email step', async () => { + action = { ...emailAction(), type: 'function' } as HogFlowAction + const workflow = workflowLogic({ id: 'new', templateId: 'first-run-example' }) + workflow.mount() + await expectLogic(workflow).toDispatchActions(['loadWorkflowSuccess']) + workflow.actions.setWorkflowInfo({ + edges: [ + { from: 'trigger_node', to: 'email', type: 'continue' }, + { from: 'email', to: 'exit_node', type: 'continue' }, + ], + }) + const editor = hogFlowEditorLogic({ id: 'new', templateId: 'first-run-example' }) + editor.mount() + + await expectLogic(editor, () => editor.actions.duplicateNodeBelow('email')).toFinishAllListeners() + + const emails = workflow.values.workflow.actions.filter((step) => step.type === 'function') + expect(emails).toHaveLength(2) + expect(emails.map((step) => step.type === 'function' && step.config.inputs?.email?.value?.from)).toEqual([ + {}, + {}, + ]) + expect(ensureCalls).toBe(0) + editor.unmount() + workflow.unmount() + }) + + it('assigns the sandbox sender before an inserted email enters the editor', async () => { + const workflow = workflowLogic({ id: 'new' }) + workflow.mount() + await expectLogic(workflow).toDispatchActions(['loadWorkflowSuccess']) + const editor = hogFlowEditorLogic({ id: 'new' }) + editor.mount() + editor.actions.setNodeToBeAdded(emailAction()) + + await expectLogic(editor, () => { + editor.actions.onDrop(undefined, NEW_WORKFLOW.edges[0]) + editor.actions.setNodeToBeAdded(null) + }).toDispatchActions(['setWorkflowInfo']) + + expect(workflow.values.workflow.actions.find((step) => step.type === 'function_email')).toMatchObject({ + config: { inputs: { email: { value: { from: { integrationId: SANDBOX.id }, subject: 'Welcome' } } } }, + }) + expect(ensureCalls).toBe(1) + editor.unmount() + workflow.unmount() + }) + + it('assigns the sender to the palette email before template defaults are available', async () => { + const workflow = workflowLogic({ id: 'new' }) + workflow.mount() + await expectLogic(workflow).toDispatchActions(['loadWorkflowSuccess']) + const editor = hogFlowEditorLogic({ id: 'new' }) + editor.mount() + editor.actions.setNodeToBeAdded(PALETTE_EMAIL_STEP) + await expectLogic(editor, () => editor.actions.onDrop(undefined, NEW_WORKFLOW.edges[0])).toDispatchActions([ + 'setWorkflowInfo', + ]) + expect(workflow.values.workflow.actions.find((step) => step.type === 'function_email')).toMatchObject({ + config: { inputs: { email: { value: { from: { integrationId: SANDBOX.id } } } } }, + }) + editor.unmount() + workflow.unmount() + }) + + it('shows pending insertion and accepts only one drop while the sender resolves', async () => { + const workflow = workflowLogic({ id: 'new' }) + workflow.mount() + await expectLogic(workflow).toDispatchActions(['loadWorkflowSuccess']) + const editor = hogFlowEditorLogic({ id: 'new' }) + editor.mount() + editor.actions.setNodeToBeAdded(emailAction()) + const preventDefault = jest.fn() + await expectLogic(editor, () => { + editor.actions.onDrop(undefined, NEW_WORKFLOW.edges[0]) + expect(editor.values.emailInsertionPending).toBe(true) + editor.actions.onDrop({ preventDefault } as unknown as React.DragEvent, NEW_WORKFLOW.edges[0]) + }).toDispatchActions(['setWorkflowInfo']) + expect(preventDefault).toHaveBeenCalledTimes(1) + expect(editor.values.emailInsertionPending).toBe(false) + expect(workflow.values.workflow.actions.filter((step) => step.type === 'function_email')).toHaveLength(1) + editor.unmount() + workflow.unmount() + }) + + it('cancels the insertion if its target edge changes while resolving the sender', async () => { + const workflow = workflowLogic({ id: 'new' }) + workflow.mount() + await expectLogic(workflow).toDispatchActions(['loadWorkflowSuccess']) + const editor = hogFlowEditorLogic({ id: 'new' }) + editor.mount() + editor.actions.setNodeToBeAdded(emailAction()) + const insertion = editor.asyncActions.onDrop(undefined, NEW_WORKFLOW.edges[0]) + workflow.actions.setWorkflowValues({ edges: [] }) + await insertion + expect(workflow.values.workflow.actions.filter((step) => step.type === 'function_email')).toHaveLength(0) + expect(editor.values.emailInsertionPending).toBe(false) + editor.unmount() + workflow.unmount() + }) + + it('does not apply a pending drop to a reopened blank workflow', async () => { + let finishProvisioning!: () => void + let provisioningStarted!: () => void + const pendingProvisioning = new Promise((resolve) => (finishProvisioning = resolve)) + const started = new Promise((resolve) => (provisioningStarted = resolve)) + useMocks({ + post: { + '/api/projects/:team_id/integrations/email_sandbox_sender/': async () => { + provisioningStarted() + await pendingProvisioning + integrations = [SANDBOX] + return [200, SANDBOX] + }, + }, + }) + const workflow = workflowLogic({ id: 'new' }) + workflow.mount() + await expectLogic(workflow).toDispatchActions(['loadWorkflowSuccess']) + const editor = hogFlowEditorLogic({ id: 'new' }) + editor.mount() + editor.actions.setNodeToBeAdded(emailAction()) + const insertion = editor.asyncActions.onDrop(undefined, NEW_WORKFLOW.edges[0]) + await started + editor.unmount() + workflow.unmount() + workflow.mount() + await expectLogic(workflow).toDispatchActions(['loadWorkflowSuccess']) + editor.mount() + finishProvisioning() + await insertion + expect(workflow.values.workflow.actions.filter((step) => step.type === 'function_email')).toHaveLength(0) + editor.unmount() + workflow.unmount() + }) + + it('holds the save of a new workflow until its email step has a sender', async () => { + let finishProvisioning!: () => void + let provisioningStarted!: () => void + const pendingProvisioning = new Promise((resolve) => (finishProvisioning = resolve)) + const started = new Promise((resolve) => (provisioningStarted = resolve)) + let saves = 0 + useMocks({ + post: { + '/api/projects/:team_id/integrations/email_sandbox_sender/': async () => { + provisioningStarted() + await pendingProvisioning + integrations = [SANDBOX] + return [200, SANDBOX] + }, + '/api/projects/:team_id/hog_flows/': () => { + saves++ + return [201, { ...NEW_WORKFLOW, id: 'saved-workflow' }] + }, + }, + }) + const toast = jest.spyOn(lemonToast, 'info').mockImplementation() + const workflow = workflowLogic({ id: 'new' }) + workflow.mount() + await expectLogic(workflow).toDispatchActions(['loadWorkflowSuccess']) + workflow.actions.setWorkflowValues({ name: 'Welcome' }) + const editor = hogFlowEditorLogic({ id: 'new' }) + editor.mount() + editor.actions.setNodeToBeAdded(emailAction()) + const insertion = editor.asyncActions.onDrop(undefined, NEW_WORKFLOW.edges[0]) + await started + + expect(workflow.values.saveBlockedReason).toEqual('An email step is still being added. Try again in a moment.') + await expectLogic(workflow, () => workflow.actions.submitWorkflow()).toDispatchActions([ + 'submitWorkflowSuccess', + ]) + expect(saves).toEqual(0) + expect(toast).toHaveBeenCalledWith('An email step is still being added. Try again in a moment.') + + finishProvisioning() + await insertion + expect(workflow.values.saveBlockedReason).toBeNull() + expect(workflow.values.workflow.actions.filter((step) => step.type === 'function_email')).toHaveLength(1) + toast.mockRestore() + editor.unmount() + workflow.unmount() + }) + + it('waits for pending template defaults before inserting the palette email', async () => { + let finishTemplates!: () => void + const templatesReady = new Promise((resolve) => (finishTemplates = resolve)) + useMocks({ + get: { + '/api/projects/:team_id/hog_function_templates/': async () => { + await templatesReady + return [ + 200, + { + count: 1, + results: [ + { + id: 'template-email', + name: 'Email', + code: '', + inputs_schema: [ + { + key: 'email', + type: 'email', + default: { subject: 'Default subject', from: {} }, + }, + ], + }, + ], + }, + ] + }, + }, + }) + const workflow = workflowLogic({ id: 'new' }) + workflow.mount() + await expectLogic(workflow).toDispatchActions(['loadWorkflowSuccess']) + const editor = hogFlowEditorLogic({ id: 'new' }) + editor.mount() + editor.actions.setNodeToBeAdded(PALETTE_EMAIL_STEP) + const insertion = editor.asyncActions.onDrop(undefined, NEW_WORKFLOW.edges[0]) + finishTemplates() + await insertion + expect(workflow.values.workflow.actions.find((step) => step.type === 'function_email')).toMatchObject({ + config: { + inputs: { email: { value: { from: { integrationId: SANDBOX.id }, subject: 'Default subject' } } }, + }, + }) + editor.unmount() + workflow.unmount() + }) + + it('keeps email insertion into a broadcast own-sender only', async () => { + const workflow = workflowLogic({ id: 'new' }) + workflow.mount() + await expectLogic(workflow).toDispatchActions(['loadWorkflowSuccess']) + workflow.actions.setWorkflowInfo({ origin_product: 'broadcasts' }) + const editor = hogFlowEditorLogic({ id: 'new' }) + editor.mount() + editor.actions.setNodeToBeAdded(emailAction()) + await expectLogic(editor, () => editor.actions.onDrop(undefined, NEW_WORKFLOW.edges[0])).toFinishAllListeners() + expect(workflow.values.workflow.actions.find((step) => step.type === 'function_email')).toMatchObject({ + config: { inputs: { email: { value: { from: {} } } } }, + }) + expect(ensureCalls).toBe(0) + editor.unmount() + workflow.unmount() + }) + + it('preserves configured Reply-To without assigning an incompatible sandbox sender', async () => { + action = emailAction() + if (action.type === 'function_email') { + action.config.inputs!.email.value.replyTo = 'reply@example.com' + } + const logic = workflowLogic({ id: 'new', templateId: 'first-run-example' }) + logic.mount() + await expectLogic(logic).toDispatchActions(['loadWorkflowSuccess']) + const email = logic.values.workflow.actions.find((step) => step.id === 'email') + expect(email?.type === 'function_email' && email.config.inputs?.email?.value).toEqual({ + from: {}, + replyTo: 'reply@example.com', + to: { email: 'member@example.com' }, + subject: 'Welcome', + html: '

Your account is ready.

', + }) + expect(ensureCalls).toBe(0) + logic.unmount() + }) + + it('leaves the sender empty when the existing integration list cannot be loaded', async () => { + useMocks({ get: { '/api/projects/:team_id/integrations/': () => [500, { detail: 'Unavailable' }] } }) + const logic = workflowLogic({ id: 'new', templateId: 'first-run-example' }) + logic.mount() + await expectLogic(logic).toDispatchActions(['loadWorkflowSuccess']) + expect(ensureCalls).toBe(0) + const email = logic.values.workflow.actions.find((step) => step.id === 'email') + expect(email?.type === 'function_email' && email.config.inputs?.email?.value?.from).toEqual({}) + logic.unmount() + }) + + it('retries a transient provisioning failure on the next explicit template load', async () => { + useMocks({ + post: { + '/api/projects/:team_id/integrations/email_sandbox_sender/': () => { + ensureCalls++ + if (ensureCalls === 1) { + return [500, { detail: 'Unavailable' }] + } + integrations = [SANDBOX] + return [200, SANDBOX] + }, + }, + }) + const unmountIntegrations = integrationsLogic.mount() + const logic = workflowLogic({ id: 'new', templateId: 'first-run-example' }) + logic.mount() + await expectLogic(logic).toDispatchActions(['loadWorkflowSuccess']) + expect(ensureCalls).toBe(1) + await expectLogic(logic, () => logic.actions.loadWorkflow()).toDispatchActions(['loadWorkflowSuccess']) + expect(ensureCalls).toBe(2) + const email = logic.values.workflow.actions.find((step) => step.id === 'email') + expect(email?.type === 'function_email' && email.config.inputs?.email?.value?.from).toEqual({ + integrationId: SANDBOX.id, + }) + logic.unmount() + unmountIntegrations() + }) + + it.each<{ + name: string + senders: Partial[] + configured?: number + flag: boolean + expected?: number + }>([ + { + name: 'a verified own sender', + senders: [{ id: 9, kind: 'email', config: { verified: true } }], + configured: undefined, + flag: true, + expected: 9, + }, + { + name: 'multiple verified senders', + senders: [ + { id: 9, kind: 'email', config: { verified: true } }, + { id: 10, kind: 'email', config: { verified: true } }, + SANDBOX, + ], + configured: undefined, + flag: true, + expected: undefined, + }, + { name: 'a configured sender', senders: [], configured: 12, flag: true, expected: 12 }, + { name: 'the sandbox flag off', senders: [], configured: undefined, flag: false, expected: undefined }, + ])('preserves sender policy with $name', async ({ senders, configured, flag, expected }) => { + integrations = senders + action = emailAction(configured) + featureFlagLogic.actions.setFeatureFlags([], { [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: flag }) + const logic = workflowLogic({ id: 'new', templateId: 'first-run-example' }) + logic.mount() + await expectLogic(logic).toDispatchActions(['loadWorkflowSuccess']) + const email = logic.values.workflow.actions.find((step) => step.id === 'email') + expect(email?.type === 'function_email' && email.config.inputs?.email?.value?.from?.integrationId).toBe( + expected + ) + expect(ensureCalls).toBe(0) + logic.unmount() + }) +}) diff --git a/products/workflows/frontend/Workflows/workflowLogic.ts b/products/workflows/frontend/Workflows/workflowLogic.ts index d57675e5e4b7..e816bffb82b0 100644 --- a/products/workflows/frontend/Workflows/workflowLogic.ts +++ b/products/workflows/frontend/Workflows/workflowLogic.ts @@ -22,7 +22,6 @@ import { objectsEqual } from 'lib/utils/objects' import { sanitizeInputs } from 'scenes/hog-functions/configuration/hogFunctionConfigurationLogic' import type { EmailFieldErrors } from 'scenes/hog-functions/email-templater/types' import { projectLogic } from 'scenes/projectLogic' -import { teamLogic } from 'scenes/teamLogic' import { urls } from 'scenes/urls' import { userLogic } from 'scenes/userLogic' @@ -41,7 +40,8 @@ import { } from '../Broadcasts/broadcastLifecycle' import { canEditInWizard, isBroadcastShaped } from '../Broadcasts/broadcastsLogic' import { loadEntrySource, saveEntrySource } from '../Broadcasts/broadcastUsage' -import { resolveDefaultEmailSender } from '../Channels/defaultEmailSender' +import { firstRunSenderLogic } from '../firstRun/firstRunSenderLogic' +import { acceptsSandboxSender, needsFirstRunSender, withFirstRunSender } from '../firstRun/withFirstRunSender' import { AiEmailDraftResult, aiEmailDraftFailureMessage, @@ -76,7 +76,7 @@ import { import { openPublishConfirmDialog } from './PublishImpactDialog' import { ResourceSaveQueue } from './resourceSaveQueue' import { prepareWorkflowDuplicate } from './workflowDuplication' -import { prefilledEmailContent, prefilledWorkflow, withEmailSender, withPrefilledEmail } from './workflowEmailPrefill' +import { prefilledEmailContent, prefilledWorkflow, withPrefilledEmail } from './workflowEmailPrefill' import { workflowSceneLogic } from './workflowSceneLogic' import { workflowsLogic } from './workflowsLogic' import { parseWorkflowTriggerPrefill } from './workflowTriggerPrefill' @@ -92,6 +92,7 @@ export interface WorkflowLogicProps { } export const TRIGGER_NODE_ID = 'trigger_node' +export const EMAIL_INSERTION_PENDING_REASON = 'An email step is still being added. Try again in a moment.' export const EXIT_NODE_ID = 'exit_node' export type TriggerAction = Extract @@ -149,25 +150,30 @@ export const PERSON_DEPENDENT_ACTION_TYPES = new Set(['wait_until_condition', 'r // the backend's ROW_SCOPED_TRIGGER_TYPES, which is the authoritative check. export const ROW_SCOPED_TRIGGER_TYPES = new Set(['data-warehouse-table', 'data-warehouse-view', 'internal-event']) -async function withDefaultEmailSender(workflow: HogFlow): Promise { +async function withFirstRunEmailSender>(workflow: T): Promise { + if (!workflow.actions.some(needsFirstRunSender)) { + return workflow + } + const senderLogic = firstRunSenderLogic({ allowSandbox: workflow.actions.some(acceptsSandboxSender) }) + const unmount = senderLogic.mount() try { - const team = teamLogic.findMounted()?.values.currentTeam - const sender = resolveDefaultEmailSender( - (await api.integrations.list()).results, - team && 'workflows_config' in team ? team.workflows_config?.default_email_integration_id : null - ) + await senderLogic.asyncActions.resolveFirstRunSender() + const sender = senderLogic.values.resolvedFirstRunSender if (!sender) { return workflow } - const result = withEmailSender(workflow, sender.integrationId) - if (result.filled) { - // pinned: analytics event name - posthog.capture('email sender preselected', { surface: 'workflow', reason: sender.reason }) + const actions = withFirstRunSender(workflow.actions, sender) + if (actions.every((action, index) => action === workflow.actions[index])) { + return workflow } - return result.workflow + // pinned: analytics event name + posthog.capture('email sender preselected', { surface: 'workflow', reason: sender.reason }) + return { ...workflow, actions } } catch { // The sender is a convenience. The person can still pick one, so a failed lookup doesn't block the editor. return workflow + } finally { + unmount() } } @@ -287,6 +293,7 @@ export interface workflowLogicValues { discardDisabledReason: string | undefined draftActionPending: 'discard' | 'publish' | null edgesByActionId: Record + emailInsertionPending: boolean emailSendingPauseRequiresSupport: boolean emailSendingPaused: boolean emailSendingPausedByStaff: boolean @@ -319,6 +326,7 @@ export interface workflowLogicValues { resumeEmailSendingPending: boolean saveAttemptedActionIds: string[] | null saveBaseUpdatedAt: string | null + saveBlockedReason: string | null scheduleConfigSources: { natural_language: boolean picker: boolean @@ -2561,6 +2569,9 @@ export interface workflowLogicActions { setDraftActionPending: (pending: 'discard' | 'publish' | null) => { pending: 'discard' | 'publish' | null } + setEmailInsertionPending: (pending: boolean) => { + pending: boolean + } setExternallyEdited: (externallyEdited: boolean) => { externallyEdited: boolean } @@ -3000,6 +3011,7 @@ export interface workflowLogicActions { export interface workflowLogicMeta { key: string __keaTypeGenInternalSelectorTypes: { + saveBlockedReason: (emailInsertionPending: any) => string | null aiEmailDraftStatus: ( aiEmailDraftLoading: boolean, aiEmailDraftApplied: boolean, @@ -3262,6 +3274,7 @@ export const workflowLogic = kea([ actions: [workflowsLogic, ['archiveWorkflow'], resourceEditedLogic, ['resourceEdited']], })), actions({ + setEmailInsertionPending: (pending: boolean) => ({ pending }), partialSetWorkflowActionConfig: (actionId: string, config: Partial) => ({ actionId, config, @@ -3350,11 +3363,11 @@ export const workflowLogic = kea([ delete (newWorkflow as any).updated_at delete (newWorkflow as any).created_by - return (await withDefaultEmailSender(newWorkflow as HogFlow)) as typeof newWorkflow + return await withFirstRunEmailSender(newWorkflow) } const triggerConfig = parseWorkflowTriggerPrefill(props.triggerPrefill) if (triggerConfig) { - return await withDefaultEmailSender( + return await withFirstRunEmailSender( prefilledWorkflow( NEW_WORKFLOW, triggerConfig, @@ -3595,6 +3608,11 @@ export const workflowLogic = kea([ if (!workflow) { return } + // Saving a new workflow replaces the route, which drops an email step whose sender is still resolving. + if (values.saveBlockedReason) { + lemonToast.info(values.saveBlockedReason) + return + } // A new workflow that only sends one email to a batch is a broadcast. Offer to create it // as one, so it opens in the broadcast editor and lists under broadcasts. @@ -3629,6 +3647,7 @@ export const workflowLogic = kea([ }, })), reducers({ + emailInsertionPending: [false, { setEmailInsertionPending: (_, { pending }) => pending }], aiEmailDraftApplied: [ false, { @@ -3798,6 +3817,11 @@ export const workflowLogic = kea([ ], }), selectors({ + saveBlockedReason: [ + (s) => [s.emailInsertionPending], + (emailInsertionPending: boolean): string | null => + emailInsertionPending ? EMAIL_INSERTION_PENDING_REASON : null, + ], aiEmailDraftStatus: [ (s) => [s.aiEmailDraftLoading, s.aiEmailDraftApplied, s.aiEmailDraft], (loading: boolean, applied: boolean, result: AiEmailDraftResult | null): AiEmailDraftStatusProps | null => { diff --git a/products/workflows/frontend/firstRun/firstRunSenderLogic.ts b/products/workflows/frontend/firstRun/firstRunSenderLogic.ts new file mode 100644 index 000000000000..63b552c89224 --- /dev/null +++ b/products/workflows/frontend/firstRun/firstRunSenderLogic.ts @@ -0,0 +1,145 @@ +import { MakeLogicType, connect, kea, key, path, props, selectors } from 'kea' +import { loaders } from 'kea-loaders' + +import { integrationsLogic } from 'lib/integrations/integrationsLogic' +import { teamLogic } from 'scenes/teamLogic' + +import type { IntegrationType, TeamPublicType, TeamType } from '~/types' + +import type { SandboxEmailSenderProvisionResult } from '../../../../frontend/src/lib/integrations/integrationsLogic' +import { DefaultEmailSender, resolveDefaultEmailSender } from '../Channels/defaultEmailSender' + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface firstRunSenderLogicValues { + currentProjectId: number | string // integrationsLogic + integrations: IntegrationType[] | null // integrationsLogic + ownEmailIntegrations: IntegrationType[] // integrationsLogic + sandboxEmailSender: IntegrationType | null // integrationsLogic + sandboxEmailSenderEnabled: boolean // integrationsLogic + sandboxEmailSenderProvisionResult: SandboxEmailSenderProvisionResult | null // integrationsLogic + currentTeam: TeamPublicType | TeamType | null // teamLogic + firstRunSender: DefaultEmailSender | null + resolvedFirstRunSender: DefaultEmailSender | null + resolvedFirstRunSenderLoading: boolean +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface firstRunSenderLogicActions { + resolveFirstRunSender: () => any + resolveFirstRunSenderFailure: ( + error: string, + errorObject?: any + ) => { + error: string + errorObject?: any + } + resolveFirstRunSenderSuccess: ( + resolvedFirstRunSender: DefaultEmailSender | null, + payload?: any + ) => { + resolvedFirstRunSender: DefaultEmailSender | null + payload?: any + } +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface firstRunSenderLogicProps { + allowSandbox: boolean +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface firstRunSenderLogicMeta { + key: 'own' | 'sandbox' + __keaTypeGenInternalSelectorTypes: { + firstRunSender: ( + integrations: IntegrationType[] | null, + ownEmailIntegrations: IntegrationType[], + sandboxEmailSender: IntegrationType | null, + currentTeam: TeamPublicType | TeamType | null + ) => DefaultEmailSender | null + } +} + +export type firstRunSenderLogicType = MakeLogicType< + firstRunSenderLogicValues, + firstRunSenderLogicActions, + firstRunSenderLogicProps, + firstRunSenderLogicMeta +> + +export const firstRunSenderLogic = kea([ + props({} as { allowSandbox: boolean }), + key((props) => (props.allowSandbox ? 'sandbox' : 'own')), + path((key) => ['products', 'workflows', 'frontend', 'firstRun', 'firstRunSenderLogic', key]), + connect({ + values: [ + integrationsLogic, + [ + 'integrations', + 'ownEmailIntegrations', + 'sandboxEmailSender', + 'sandboxEmailSenderEnabled', + 'sandboxEmailSenderProvisionResult', + 'currentProjectId', + ], + teamLogic, + ['currentTeam'], + ], + }), + selectors({ + firstRunSender: [ + (s) => [s.integrations, s.ownEmailIntegrations, s.sandboxEmailSender, s.currentTeam], + ( + integrations: IntegrationType[] | null, + ownSenders: IntegrationType[], + sandbox: IntegrationType | null, + team: TeamPublicType | TeamType | null + ): DefaultEmailSender | null => { + const own = resolveDefaultEmailSender( + integrations, + team && 'workflows_config' in team ? team.workflows_config?.default_email_integration_id : null + ) + return ( + own ?? + (sandbox && !ownSenders.some((sender) => sender.config?.verified === true) + ? { integrationId: sandbox.id, reason: 'sandbox_sender' } + : null) + ) + }, + ], + }), + loaders(({ values, props }) => ({ + resolvedFirstRunSender: [ + null as DefaultEmailSender | null, + { + resolveFirstRunSender: async (): Promise => { + const projectId = values.currentProjectId + if (values.integrations === null) { + await integrationsLogic.asyncActions.loadIntegrations() + } + if (values.currentProjectId !== projectId || values.integrations === null) { + return null + } + if (!props.allowSandbox) { + return resolveDefaultEmailSender( + values.integrations, + values.currentTeam && 'workflows_config' in values.currentTeam + ? values.currentTeam.workflows_config?.default_email_integration_id + : null + ) + } + if ( + !values.firstRunSender && + values.sandboxEmailSenderEnabled && + (!values.sandboxEmailSenderProvisionResult || + values.sandboxEmailSenderProvisionResult === 'failed') && + !values.ownEmailIntegrations.some((sender) => sender.config?.verified === true) + ) { + await integrationsLogic.asyncActions.provisionSandboxEmailSender() + } + return values.currentProjectId === projectId ? values.firstRunSender : null + }, + }, + ], + })), +]) diff --git a/products/workflows/frontend/firstRun/withFirstRunSender.ts b/products/workflows/frontend/firstRun/withFirstRunSender.ts new file mode 100644 index 000000000000..f0fe8a093f25 --- /dev/null +++ b/products/workflows/frontend/firstRun/withFirstRunSender.ts @@ -0,0 +1,45 @@ +import type { DefaultEmailSender } from '../Channels/defaultEmailSender' +import type { HogFlowAction } from '../Workflows/hogflows/types' + +export function needsFirstRunSender( + action: HogFlowAction +): action is Extract { + if ( + action.type !== 'function_email' && + !(action.type === 'function' && action.config.template_id === 'template-email') + ) { + return false + } + const from = action.config.inputs?.email?.value?.from + return !from?.integrationId && !from?.email && !from?.name +} + +export function acceptsSandboxSender(action: HogFlowAction): boolean { + return ( + action.type === 'function_email' && needsFirstRunSender(action) && !action.config.inputs?.email?.value?.replyTo + ) +} + +export function withFirstRunSender(actions: HogFlowAction[], sender: DefaultEmailSender): HogFlowAction[] { + return actions.map((action) => { + if (!needsFirstRunSender(action)) { + return action + } + if (sender.reason === 'sandbox_sender' && !acceptsSandboxSender(action)) { + return action + } + const inputs = { + ...action.config.inputs, + email: { + ...action.config.inputs?.email, + value: { + ...action.config.inputs?.email?.value, + from: { ...action.config.inputs?.email?.value?.from, integrationId: sender.integrationId }, + }, + }, + } + const updated: typeof action = { ...action } + updated.config = { ...action.config, inputs } + return updated + }) +}