Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { cn } from 'lib/utils/css-classes'
const SIZE_CLASSES = {
attachment: 'w-24 h-16 [--email-preview-scale:0.16]',
card: 'w-40 h-28 [--email-preview-scale:0.2667]',
graphNode: 'w-45 h-50 [--email-preview-scale:0.3]',
listItem: 'w-72 h-56 [--email-preview-scale:0.48]',
} as const

export interface EmailPreviewThumbnailProps {
Expand All @@ -15,7 +17,7 @@ export interface EmailPreviewThumbnailProps {
className?: string
}

/** A scaled-down, non-interactive render of an email, for composer attachments and picker cards. */
/** A scaled-down, non-interactive render of an email. */
export function EmailPreviewThumbnail({ html, title, size, className }: EmailPreviewThumbnailProps): JSX.Element {
return (
<span className={cn('block shrink-0 overflow-hidden bg-white', SIZE_CLASSES[size], className)}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { initKeaTests } from '~/test/init'

import { EXIT_NODE_ID, NEW_WORKFLOW, TRIGGER_NODE_ID, workflowLogic } from '../workflowLogic'
import { computeInsertEdges, computeMoveEdges, hogFlowEditorLogic } from './hogFlowEditorLogic'
import { NODE_WIDTH } from './react_flow_utils/constants'
import { HogFlow, HogFlowAction, HogFlowActionEdge, HogFlowActionNode } from './types'

type Edge = HogFlow['edges'][0]
Expand Down Expand Up @@ -676,6 +677,120 @@ describe('hogFlowEditorLogic', () => {
})
})

describe('email step layout', () => {
const emailInputs = { email: { value: { subject: 'Welcome', html: '<p>Welcome aboard</p>' } } }

const flowWithEmail = (emailStep: Pick<HogFlowAction, 'type' | 'config'>): HogFlow =>
({
id: 'email-flow',
team_id: 1,
version: 1,
name: 'Email flow',
status: 'draft',
exit_condition: 'exit_only_at_end',
actions: [
{
id: 'trigger',
name: 'Trigger',
description: '',
type: 'trigger',
created_at: 1,
updated_at: 1,
config: { type: 'event', filters: {} },
},
{ id: 'email', name: 'Welcome email', description: '', created_at: 1, updated_at: 1, ...emailStep },
{
id: 'exit',
name: 'Exit',
description: '',
type: 'exit',
created_at: 1,
updated_at: 1,
config: { reason: '' },
},
],
edges: [
{ from: 'trigger', to: 'email', type: 'continue' },
{ from: 'email', to: 'exit', type: 'continue' },
],
updated_at: '2026-01-01T00:00:00Z',
created_at: '2026-01-01T00:00:00Z',
}) as HogFlow

beforeEach(async () => {
await expectLogic(logic).toDispatchActions(['setNodesRaw'])
})

it.each([
{
name: 'an email step',
emailStep: { type: 'function_email', config: { template_id: 'template-email', inputs: emailInputs } },
},
{
name: 'a generic step using the email template',
emailStep: { type: 'function', config: { template_id: 'template-email', inputs: emailInputs } },
},
] as { name: string; emailStep: Pick<HogFlowAction, 'type' | 'config'> }[])(
'makes room for the preview of $name and connects the next step to the bottom of it',
async ({ emailStep }) => {
await expectLogic(logic, () =>
logic.actions.resetFlowFromHogFlow(flowWithEmail(emailStep))
).toDispatchActions(['setNodesRaw'])

const nodeById = (id: string): HogFlowActionNode => logic.values.nodes.find((node) => node.id === id)!
const trigger = nodeById('trigger')
const email = nodeById('email')
const exit = nodeById('exit')

expect(email.height).toBeGreaterThan(trigger.height!)
expect(email.handles?.find((handle) => handle.type === 'source')).toMatchObject({
x: email.width! / 2,
y: email.height,
})
expect(exit.position.y).toBeGreaterThan(email.position.y + email.height!)
}
)

it('starts sibling branches on the same row when one of them begins with an email', async () => {
const flow = flowWithEmail({
type: 'function_email',
config: { template_id: 'template-email', inputs: emailInputs },
} as Pick<HogFlowAction, 'type' | 'config'>)
flow.actions.push(
{
id: 'branch',
name: 'Branch',
description: '',
type: 'conditional_branch',
created_at: 1,
updated_at: 1,
config: { conditions: [{ filters: {} }] },
},
{
id: 'delay',
name: 'Delay',
description: '',
type: 'delay',
created_at: 1,
updated_at: 1,
config: { delay_duration: '1d' },
}
)
flow.edges = [
{ from: 'trigger', to: 'branch', type: 'continue' },
{ from: 'branch', to: 'email', type: 'branch', index: 0 },
{ from: 'branch', to: 'delay', type: 'continue' },
{ from: 'email', to: 'exit', type: 'continue' },
{ from: 'delay', to: 'exit', type: 'continue' },
]

await expectLogic(logic, () => logic.actions.resetFlowFromHogFlow(flow)).toDispatchActions(['setNodesRaw'])

const nodeById = (id: string): HogFlowActionNode => logic.values.nodes.find((node) => node.id === id)!
expect(nodeById('delay').position.y).toBe(nodeById('email').position.y)
})
})

describe('showDropzones branch-join placement', () => {
const makeNode = (id: string): HogFlowActionNode =>
({
Expand Down Expand Up @@ -743,5 +858,19 @@ describe('hogFlowEditorLogic', () => {
logic.actions.showDropzones()
expect(branchJoinDropzones()).toEqual(expected)
})

it('centers the branch-join dropzone on a target wider than a regular step', () => {
const wideTarget = { ...makeNode('email'), position: { x: 100, y: 300 }, width: 180 }
logic.actions.setNodesRaw([makeNode('trigger'), makeNode('cond'), wideTarget])
logic.actions.setEdges([
makeEdge('trigger', 'cond', 'continue'),
makeEdge('cond', 'email', 'branch', 0),
makeEdge('cond', 'email', 'continue'),
])
logic.actions.showDropzones()

const dropzone = logic.values.dropzoneNodes.find((node) => node.data.isBranchJoinDropzone)!
expect(dropzone.position.x + NODE_WIDTH / 2).toBe(wideTarget.position.x + wideTarget.width / 2)
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,8 @@ import { optOutCategoriesLogic } from '../../OptOuts/optOutCategoriesLogic'
import type { MessageCategory } from '../../OptOuts/optOutCategoriesLogic'
import { EXIT_NODE_ID, TRIGGER_NODE_ID, WorkflowLogicProps, workflowLogic } from '../workflowLogic'
import { getFormattedNodes } from './react_flow_utils/autolayout'
import {
BOTTOM_HANDLE_POSITION,
MAX_ZOOM,
MIN_ZOOM,
NODE_HEIGHT,
NODE_WIDTH,
TOP_HANDLE_POSITION,
} from './react_flow_utils/constants'
import { MAX_ZOOM, MIN_ZOOM, NODE_HEIGHT, NODE_WIDTH } from './react_flow_utils/constants'
import { bottomHandlePosition, getNodeSize, topHandlePosition } from './react_flow_utils/nodeSize'
import { getSmartStepPath } from './react_flow_utils/SmartEdge'
import { getHogFlowStep } from './steps/HogFlowSteps'
import { CyclotronInputType, StepViewNodeHandle } from './steps/types'
Expand Down Expand Up @@ -2535,6 +2529,9 @@ export const hogFlowEditorLogic = kea<hogFlowEditorLogicType>([
})

const handlesByIdByNodeId: Record<string, Record<string, StepViewNodeHandle>> = {}
const nodeSizeById = Object.fromEntries(
hogFlow.actions.map((action) => [action.id, getNodeSize(action)])
)

edges.forEach((edge) => {
if (!handlesByIdByNodeId[edge.source]) {
Expand All @@ -2548,14 +2545,14 @@ export const hogFlowEditorLogic = kea<hogFlowEditorLogicType>([
id: edge.sourceHandle,
type: 'source',
position: Position.Bottom,
...BOTTOM_HANDLE_POSITION,
...bottomHandlePosition(nodeSizeById[edge.source]),
}

handlesByIdByNodeId[edge.target][edge.targetHandle ?? ''] = {
id: edge.targetHandle,
type: 'target',
position: Position.Top,
...TOP_HANDLE_POSITION,
...topHandlePosition(nodeSizeById[edge.target]),
}
})

Expand Down Expand Up @@ -2709,7 +2706,7 @@ export const hogFlowEditorLogic = kea<hogFlowEditorLogicType>([
id: branchJoinDropzoneTargetId,
type: 'dropzone',
position: {
x: targetNode.position.x,
x: targetNode.position.x + ((targetNode.width ?? NODE_WIDTH) - NODE_WIDTH) / 2,
y: targetNode.position.y - NODE_HEIGHT,
},
data: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { getElk } from 'lib/elk'

import { TRIGGER_NODE_ID } from '../../workflowLogic'
import type { HogFlowActionNode } from '../types'
import { NODE_EDGE_GAP, NODE_HEIGHT, NODE_LAYER_GAP, NODE_NODE_GAP, NODE_WIDTH } from './constants'
import { NODE_EDGE_GAP, NODE_LAYER_GAP, NODE_NODE_GAP } from './constants'
import { getNodeSize } from './nodeSize'

/**
* By default, React Flow does not do any layouting of nodes or edges. This file uses the ELK Layered algorithm
Expand Down Expand Up @@ -57,12 +58,13 @@ export const getFormattedNodes = async (nodes: HogFlowActionNode[], edges: Edge[

return {
...node,
width: NODE_WIDTH,
height: NODE_HEIGHT,
...getNodeSize(node.data),
targetPosition: 'top',
sourcePosition: 'bottom',
properties: {
'org.eclipse.elk.portConstraints': 'FIXED_ORDER',
// Email steps are taller, so centering within a layer would push their sibling branches down.
'org.eclipse.elk.alignment': 'TOP',
},
ports: [...handles],
}
Expand Down Expand Up @@ -109,8 +111,7 @@ export const getFormattedNodes = async (nodes: HogFlowActionNode[], edges: Edge[
}
return {
...node,
width: NODE_WIDTH,
height: NODE_HEIGHT,
...getNodeSize(node.data),
position: { x: layouted?.x ?? 0, y: layouted?.y ?? 0 },
}
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,23 +20,3 @@ export const MAX_ZOOM = 2
// Below this, a node's description renders as an illegible smudge (it's 0.3rem at zoom 1), so we drop
// it and leave the node as a labelled block for reading the overall shape of the workflow.
export const LOW_DETAIL_ZOOM = 0.4

export const TOP_HANDLE_POSITION = {
x: NODE_WIDTH / 2,
y: 0,
}

export const BOTTOM_HANDLE_POSITION = {
x: NODE_WIDTH / 2,
y: NODE_HEIGHT,
}

export const LEFT_HANDLE_POSITION = {
x: 0,
y: NODE_HEIGHT / 2,
}

export const RIGHT_HANDLE_POSITION = {
x: NODE_WIDTH,
y: NODE_HEIGHT / 2,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { getEmailStepHtml } from '../steps/emailStepHtml'
import type { HogFlowAction } from '../types'
import { NODE_HEIGHT, NODE_WIDTH } from './constants'

// Matches the `graphNode` size of EmailPreviewThumbnail, which renders inside the node border.
const EMAIL_THUMBNAIL_WIDTH = 180
const NODE_BORDER_WIDTH = 1
const EMAIL_NODE_WIDTH = EMAIL_THUMBNAIL_WIDTH + 2 * NODE_BORDER_WIDTH
const EMAIL_NODE_PREVIEW_HEIGHT = 200

export type NodeSize = { width: number; height: number }

export const DEFAULT_NODE_SIZE: NodeSize = { width: NODE_WIDTH, height: NODE_HEIGHT }

export function getNodeSize(action: HogFlowAction): NodeSize {
if (getEmailStepHtml(action)) {
return { width: EMAIL_NODE_WIDTH, height: NODE_HEIGHT + EMAIL_NODE_PREVIEW_HEIGHT }
}
return DEFAULT_NODE_SIZE
}

export function topHandlePosition({ width }: NodeSize): { x: number; y: number } {
return { x: width / 2, y: 0 }
}

export function bottomHandlePosition({ width, height }: NodeSize): { x: number; y: number } {
return { x: width / 2, y: height }
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ function HogFlowActionNode(props: HogFlowStepNodeProps): JSX.Element | null {
// isConnectable={false} prevents edges from being manually added
<Handle key={handle.id} className="opacity-0" {...handle} isConnectable={false} />
))}
<StepView action={props.data} />
<StepView action={props.data} showEmailPreview />
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,29 @@ import { useMemo } from 'react'
import { IconCopy, IconDrag, IconEllipsis, IconTrash } from '@posthog/icons'
import { LemonInput, LemonTextArea, Tooltip } from '@posthog/lemon-ui'

import { EmailPreviewThumbnail } from 'lib/components/EmailPreviewThumbnail/EmailPreviewThumbnail'
import { LemonBadge } from 'lib/lemon-ui/LemonBadge'
import { LemonButton } from 'lib/lemon-ui/LemonButton'
import { LemonMenu } from 'lib/lemon-ui/LemonMenu'

import { workflowLogic } from '../../../workflowLogic'
import { hogFlowEditorLogic } from '../../hogFlowEditorLogic'
import { NODE_HEIGHT, NODE_WIDTH } from '../../react_flow_utils/constants'
import { DEFAULT_NODE_SIZE, getNodeSize } from '../../react_flow_utils/nodeSize'
import { HogFlowAction } from '../../types'
import { getEmailStepHtml } from '../emailStepHtml'
import { useHogFlowStep } from '../HogFlowSteps'
import { isScheduleTrigger } from '../types'
import { buildSummary } from './rrule-helpers'
import { StepViewLogicProps, stepViewLogic } from './stepViewLogic'
import { StepViewMetrics } from './StepViewMetrics'

export function StepView({ action }: { action: HogFlowAction }): JSX.Element {
export function StepView({
action,
showEmailPreview = false,
}: {
action: HogFlowAction
showEmailPreview?: boolean
}): JSX.Element {
const {
selectedNode,
mode,
Expand Down Expand Up @@ -67,7 +75,9 @@ export function StepView({ action }: { action: HogFlowAction }): JSX.Element {
} = useActions(stepViewLogic(stepViewLogicProps))

const shouldShowMetricsSummary = mode === 'metrics' && workflow.trigger?.type !== 'batch'
const height = shouldShowMetricsSummary ? NODE_HEIGHT + 10 : NODE_HEIGHT
const emailHtml = showEmailPreview ? getEmailStepHtml(action) : undefined
const { width, height: nodeHeight } = showEmailPreview ? getNodeSize(action) : DEFAULT_NODE_SIZE
const height = shouldShowMetricsSummary ? nodeHeight + 10 : nodeHeight

const Step = useHogFlowStep(action)
const { selectedColor, colorLight, color, icon } = useMemo(() => {
Expand All @@ -93,7 +103,7 @@ export function StepView({ action }: { action: HogFlowAction }): JSX.Element {
<div
className="relative flex flex-col cursor-pointer rounded user-select-none bg-surface-primary transition-[border-color] duration-300"
style={{
width: NODE_WIDTH,
width,
height,
borderWidth: 1,
borderColor: isAnimationTarget ? 'var(--success)' : selectedColor,
Expand Down Expand Up @@ -250,6 +260,14 @@ export function StepView({ action }: { action: HogFlowAction }): JSX.Element {
<LemonBadge status="warning" size="small" content="!" position="top-right" />
</div>
) : null}
{emailHtml && (
<EmailPreviewThumbnail
html={emailHtml}
title={`${action.name} preview`}
size="graphNode"
className="flex-1 min-h-0 rounded-b-sm dark:invert dark:hue-rotate-180"
/>
)}
{shouldShowMetricsSummary && (
<div
style={{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { HogFlowAction } from '../types'
import { isFunctionAction } from './types'

export function getEmailStepHtml(action: HogFlowAction): string | undefined {
if (!isFunctionAction(action) || action.config.template_id !== 'template-email') {
return undefined
}
const html: unknown = action.config.inputs?.email?.value?.html
return typeof html === 'string' && html ? html : undefined
}
Loading
Loading