feat(workflows): manual trigger run options (#40906)

Co-authored-by: meikel <meikel@posthog.com>
This commit is contained in:
Haven
2025-11-06 14:00:11 -06:00
committed by GitHub
parent 3de75adaf4
commit b49424a513
13 changed files with 369 additions and 64 deletions

View File

@@ -163,6 +163,7 @@ export class CdpSourceWebhooksConsumer extends CdpConsumerBase {
query,
stringBody: req.rawBody ?? '',
},
variables: req.body.$variables || {},
}
}

View File

@@ -71,6 +71,7 @@ export class HogFunctionHandler implements ActionHandler {
{
event: invocation.state.event,
person: invocation.person,
variables: invocation.state.variables,
}
)

View File

@@ -17,7 +17,7 @@ import { HogExecutorService } from '../hog-executor.service'
import { HogFunctionTemplateManagerService } from '../managers/hog-function-template-manager.service'
import { RecipientsManagerService } from '../managers/recipients-manager.service'
import { RecipientPreferencesService } from '../messaging/recipient-preferences.service'
import { HogFlowExecutorService } from './hogflow-executor.service'
import { HogFlowExecutorService, createHogFlowInvocation } from './hogflow-executor.service'
import { HogFlowFunctionsService } from './hogflow-functions.service'
// Mock before importing fetch
@@ -1075,4 +1075,60 @@ describe('Hogflow Executor', () => {
})
})
})
describe('variable merging', () => {
it('merges default and provided variables correctly', () => {
const hogFlow: HogFlow = new FixtureHogFlowBuilder()
.withWorkflow({
actions: {
trigger: {
type: 'trigger',
config: {
type: 'event',
filters: {},
},
},
exit: {
type: 'exit',
config: {},
},
},
edges: [{ from: 'trigger', to: 'exit', type: 'continue' }],
})
.build()
// Set variables directly with required fields
hogFlow.variables = [
{ key: 'foo', default: 'bar', type: 'string', label: 'foo' },
{ key: 'baz', default: 123, type: 'number', label: 'baz' },
{ key: 'overrideMe', default: 'defaultValue', type: 'string', label: 'overrideMe' },
]
const globals = {
event: {
event: 'test',
properties: {},
url: '',
distinct_id: '',
timestamp: '',
uuid: '',
elements_chain: '',
},
project: { id: 1, name: 'Test Project', url: '' },
person: { id: 'person_id', name: '', properties: {}, url: '' },
variables: {
overrideMe: 'customValue',
extra: 'shouldBeIncluded',
},
}
const invocation = createHogFlowInvocation(globals, hogFlow, {} as any)
expect(invocation.state.variables).toEqual({
foo: 'bar',
baz: 123,
overrideMe: 'customValue',
extra: 'shouldBeIncluded',
})
})
})
})

View File

@@ -37,21 +37,27 @@ export function createHogFlowInvocation(
hogFlow: HogFlow,
filterGlobals: HogFunctionFilterGlobals
): CyclotronJobInvocationHogFlow {
// Build default variables from hogFlow, then merge in any provided in globals.variables
const defaultVariables =
hogFlow.variables?.reduce(
(acc, variable) => {
acc[variable.key] = variable.default || null
return acc
},
{} as Record<string, any>
) || {}
const mergedVariables = {
...defaultVariables,
...(globals.variables || {}),
}
return {
id: new UUIDT().toString(),
state: {
event: globals.event,
actionStepCount: 0,
variables: {
// Spread in any existing variables from hogflow so the default values are in place
...hogFlow.variables?.reduce(
(acc, variable) => {
acc[variable.key] = variable.default || null
return acc
},
{} as Record<string, any>
),
},
variables: mergedVariables,
},
teamId: hogFlow.team_id,
functionId: hogFlow.id, // TODO: Include version?

View File

@@ -34,7 +34,7 @@ export const getPersonDisplayName = (team: Team, distinctId: string, properties:
const customIdentifier: string =
typeof propertyIdentifier !== 'string' ? JSON.stringify(propertyIdentifier) : propertyIdentifier
return (customIdentifier || distinctId)?.trim()
return (customIdentifier || String(distinctId))?.trim()
}
// that we can keep to as a contract

View File

@@ -1,21 +1,41 @@
import { useActions, useValues } from 'kea'
import { useEffect, useRef, useState } from 'react'
import { IconButton } from '@posthog/icons'
import { LemonButton } from '@posthog/lemon-ui'
import { LemonButton, LemonDivider } from '@posthog/lemon-ui'
import { SceneTitleSection } from '~/layout/scenes/components/SceneTitleSection'
import { HogFlowManualTriggerButton } from './hogflows/HogFlowManualTriggerButton'
import { workflowLogic } from './workflowLogic'
import { WorkflowSceneLogicProps } from './workflowSceneLogic'
export const WorkflowSceneHeader = (props: WorkflowSceneLogicProps = {}): JSX.Element => {
const logic = workflowLogic(props)
const { workflow, workflowChanged, isWorkflowSubmitting, workflowLoading, workflowHasErrors } = useValues(logic)
const { saveWorkflowPartial, submitWorkflow, discardChanges, setWorkflowValue, triggerManualWorkflow } =
useActions(logic)
const { saveWorkflowPartial, submitWorkflow, discardChanges, setWorkflowValue } = useActions(logic)
const isSavedWorkflow = props.id && props.id !== 'new'
const isManualWorkflow = workflow?.trigger?.type === 'manual'
const [displayStatus, setDisplayStatus] = useState(workflow?.status)
const [isTransitioning, setIsTransitioning] = useState(false)
const prevStatusRef = useRef(workflow?.status)
useEffect(() => {
// Only transition if status actually changed (not on initial mount)
if (workflow?.status !== displayStatus && prevStatusRef.current !== undefined) {
setIsTransitioning(true)
const timer = setTimeout(() => {
setDisplayStatus(workflow?.status)
setIsTransitioning(false)
}, 150)
prevStatusRef.current = workflow?.status
return () => clearTimeout(timer)
} else if (workflow?.status !== displayStatus) {
// On initial mount, just set it without transition
setDisplayStatus(workflow?.status)
prevStatusRef.current = workflow?.status
}
}, [workflow?.status, displayStatus])
return (
<>
@@ -26,52 +46,45 @@ export const WorkflowSceneHeader = (props: WorkflowSceneLogicProps = {}): JSX.El
canEdit
onNameChange={(name) => setWorkflowValue('name', name)}
onDescriptionChange={(description) => setWorkflowValue('description', description)}
isLoading={workflowLoading}
isLoading={workflowLoading && !workflow}
renameDebounceMs={200}
actions={
<>
{isManualWorkflow && (
<LemonButton
type="primary"
disabledReason={workflow?.status !== 'active' && 'Must enable workflow to use trigger'}
icon={<IconButton />}
tooltip="Triggers workflow immediately"
onClick={triggerManualWorkflow}
>
Trigger
</LemonButton>
)}
{isManualWorkflow && <HogFlowManualTriggerButton {...props} />}
{isSavedWorkflow && (
<>
<LemonButton
type="primary"
type={displayStatus === 'active' ? 'primary' : 'secondary'}
onClick={() =>
saveWorkflowPartial({
status: workflow?.status === 'draft' ? 'active' : 'draft',
})
}
size="small"
loading={workflowLoading}
disabledReason={workflowChanged ? 'Save changes first' : undefined}
className="transition-colors duration-300 ease-in-out"
>
{workflow?.status === 'draft' ? 'Enable' : 'Disable'}
<span
className={`inline-block transition-opacity duration-300 ease-in-out ${
isTransitioning ? 'opacity-0' : 'opacity-100'
}`}
>
{displayStatus === 'draft' ? 'Enable' : 'Disable'}
</span>
</LemonButton>
<LemonDivider vertical />
</>
)}
{isSavedWorkflow && workflowChanged && (
<>
<LemonButton
data-attr="discard-workflow-changes"
type="secondary"
onClick={() => discardChanges()}
size="small"
>
Discard changes
</LemonButton>
</>
{workflowChanged && (
<LemonButton
data-attr="discard-workflow-changes"
type="secondary"
onClick={() => discardChanges()}
size="small"
>
Clear changes
</LemonButton>
)}
<LemonButton
type="primary"
size="small"

View File

@@ -0,0 +1,141 @@
import { useActions, useValues } from 'kea'
import { IconChevronDown } from '@posthog/icons'
import { LemonButton, LemonInput, Popover } from '@posthog/lemon-ui'
import { LemonField } from 'lib/lemon-ui/LemonField'
import { CyclotronJobInputSchemaType } from '~/types'
import { WorkflowLogicProps, workflowLogic } from '../workflowLogic'
import { hogFlowManualTriggerButtonLogic } from './HogFlowManualTriggerButtonLogic'
const VariableInputsPopover = ({
setPopoverVisible,
props,
}: {
setPopoverVisible: (visible: boolean) => void
props: WorkflowLogicProps
}): JSX.Element => {
const logic = hogFlowManualTriggerButtonLogic(props)
const { workflow, variableValues, inputs } = useValues(logic)
const { triggerManualWorkflow } = useActions(workflowLogic(props))
const { setInput, clearInputs } = useActions(logic)
if (!workflow?.variables || workflow.variables.length === 0) {
return (
<div className="flex flex-col gap-3 p-3 min-w-80">
<div className="pb-2 border-b">
<h3 className="text-sm font-semibold">Configure variables</h3>
</div>
<div className="text-muted text-sm">No variables to configure.</div>
<div className="flex justify-end border-t pt-3">
<LemonButton
type="primary"
status="alt"
onClick={() => {
triggerManualWorkflow({})
setPopoverVisible(false)
clearInputs()
}}
data-attr="run-workflow-btn"
>
Run workflow
</LemonButton>
</div>
</div>
)
}
return (
<div className="flex flex-col gap-4 p-3 min-w-80 max-w-96">
<div className="pb-2 border-b">
<h3 className="text-sm font-semibold">Configure variables</h3>
<p className="text-xs text-muted mt-0.5">Set variable values or leave empty to use defaults</p>
</div>
<div className="flex flex-col gap-3">
{workflow.variables.map((variable: CyclotronJobInputSchemaType) => {
const inputValue = inputs[variable.key]
const displayValue = inputValue ?? ''
const hasDefault = variable.default !== undefined && variable.default !== ''
return (
<LemonField.Pure key={variable.key} label={variable.label || variable.key}>
{variable.type === 'number' ? (
<LemonInput
type="number"
value={displayValue === '' ? undefined : Number(displayValue)}
placeholder={hasDefault ? `Default: ${String(variable.default)}` : 'Enter value'}
onChange={(value: number | undefined) => {
setInput(variable.key, value !== undefined ? String(value) : '')
}}
/>
) : (
<LemonInput
type="text"
value={displayValue}
placeholder={hasDefault ? `Default: ${String(variable.default)}` : 'Enter value'}
onChange={(value: string) => {
setInput(variable.key, value)
}}
/>
)}
</LemonField.Pure>
)
})}
</div>
<div className="flex justify-end border-t pt-3">
<LemonButton
type="primary"
status="alt"
onClick={() => {
triggerManualWorkflow(variableValues)
setPopoverVisible(false)
clearInputs()
}}
data-attr="run-workflow-btn"
>
Run workflow
</LemonButton>
</div>
</div>
)
}
export const HogFlowManualTriggerButton = (props: WorkflowLogicProps = {}): JSX.Element => {
const logic = hogFlowManualTriggerButtonLogic(props)
const { workflow, workflowChanged } = useValues(workflowLogic(props))
const { popoverVisible } = useValues(logic)
const { setPopoverVisible } = useActions(logic)
const triggerButton = (
<LemonButton
type="primary"
size="small"
disabledReason={
workflow?.status !== 'active'
? 'Must enable workflow to use trigger'
: workflowChanged
? 'Save changes first'
: undefined
}
sideIcon={<IconChevronDown className={`transition-transform ${popoverVisible ? 'rotate-180' : ''}`} />}
tooltip="Triggers workflow immediately"
onClick={() => setPopoverVisible(!popoverVisible)}
>
Trigger
</LemonButton>
)
return (
<Popover
visible={popoverVisible}
placement="bottom-start"
onClickOutside={() => setPopoverVisible(false)}
overlay={<VariableInputsPopover setPopoverVisible={setPopoverVisible} props={props} />}
>
{triggerButton}
</Popover>
)
}

View File

@@ -0,0 +1,77 @@
import { actions, connect, kea, path, props, reducers, selectors } from 'kea'
import { CyclotronJobInputSchemaType } from '~/types'
import { WorkflowLogicProps, workflowLogic } from '../workflowLogic'
import type { hogFlowManualTriggerButtonLogicType } from './HogFlowManualTriggerButtonLogicType'
const parseValue = (value: string, variableType: string): any => {
if (value === '') {
return undefined
}
switch (variableType) {
case 'number':
const num = Number(value)
return isNaN(num) ? value : num
case 'boolean':
if (value.toLowerCase() === 'true') {
return true
}
if (value.toLowerCase() === 'false') {
return false
}
return value
default:
return value
}
}
export const hogFlowManualTriggerButtonLogic = kea<hogFlowManualTriggerButtonLogicType>([
path(['products', 'workflows', 'frontend', 'Workflows', 'hogflows', 'hogFlowManualTriggerButtonLogic']),
props({} as WorkflowLogicProps),
connect((props: WorkflowLogicProps) => ({
values: [workflowLogic(props), ['workflow']],
actions: [workflowLogic(props), ['triggerManualWorkflow']],
})),
actions({
setInput: (key: string, value: string) => ({ key, value }),
setPopoverVisible: (visible: boolean) => ({ visible }),
clearInputs: () => ({}),
}),
reducers({
inputs: [
{} as Record<string, string>,
{
setInput: (state, { key, value }) => ({ ...state, [key]: value }),
clearInputs: () => ({}),
},
],
popoverVisible: [
false,
{
setPopoverVisible: (_, { visible }) => visible,
},
],
}),
selectors({
variableValues: [
(s) => [s.inputs, s.workflow],
(inputs: Record<string, string>, workflow: any): Record<string, any> => {
if (!workflow?.variables) {
return {}
}
return Object.fromEntries(
workflow.variables.map((v: CyclotronJobInputSchemaType) => {
const inputValue = inputs[v.key]
if (inputValue !== undefined && inputValue !== '') {
// Parse the string input to the correct type
return [v.key, parseValue(inputValue, v.type)]
}
// Use default value as-is (preserve original type)
return [v.key, v.default]
})
)
},
],
}),
])

View File

@@ -1,6 +1,6 @@
import { useActions, useValues } from 'kea'
import { IconCode, IconPlus, IconTrash } from '@posthog/icons'
import { IconCode, IconCopy, IconPlus, IconTrash } from '@posthog/icons'
import { LemonButton, LemonDialog, LemonInput, LemonLabel, Tooltip, lemonToast } from '@posthog/lemon-ui'
import { LemonField } from 'lib/lemon-ui/LemonField/LemonField'
@@ -24,7 +24,9 @@ export function HogFlowEditorPanelVariables(): JSX.Element | null {
const editVariableKey = (idx: number, key: string): void => {
const updatedVariables = [...(workflow?.variables || [])]
updatedVariables[idx].key = key
const sanitizedKey = key.replace(/\s+/g, '_')
updatedVariables[idx].key = sanitizedKey
updatedVariables[idx].label = sanitizedKey
setWorkflowInfo({
variables: updatedVariables,
})
@@ -96,14 +98,23 @@ export function HogFlowEditorPanelVariables(): JSX.Element | null {
</LemonField.Pure>
<LemonField.Pure label="Usage syntax">
<Tooltip title={`{{ variables.${variable.key} }}`}>
<code
className="w-36 py-2 bg-primary-alt-highlight-light rounded-sm text-center text-xs truncate cursor-pointer"
onClick={(e) => {
e.stopPropagation()
void navigator.clipboard.writeText(`{{ variables.${variable.key} }}`)
lemonToast.success('Copied to clipboard')
}}
>{`{{ variables.${variable.key} }}`}</code>
<span className="group relative">
<code className="w-36 py-2 bg-primary-alt-highlight-light rounded-sm text-center text-xs truncate block">
{`{{ variables.${variable.key} }}`}
</code>
<span className="absolute top-0 right-0 z-10 p-px opacity-0 transition-opacity group-hover:opacity-100">
<LemonButton
size="small"
icon={<IconCopy />}
className="bg-white/80"
onClick={(e) => {
e.stopPropagation()
void navigator.clipboard.writeText(`{{ variables.${variable.key} }}`)
lemonToast.success('Copied to clipboard')
}}
/>
</span>
</span>
</Tooltip>
</LemonField.Pure>
<LemonButton

View File

@@ -2,7 +2,7 @@ import { useActions, useValues } from 'kea'
import { Form } from 'kea-forms'
import { useEffect } from 'react'
import { IconInfo, IconPlay, IconPlayFilled, IconRedo, IconTestTube } from '@posthog/icons'
import { IconInfo, IconPlayFilled, IconRedo, IconTestTube } from '@posthog/icons'
import {
LemonBanner,
LemonButton,
@@ -72,7 +72,7 @@ export function HogFlowEditorPanelTest(): JSX.Element | null {
<p>Step through each action in your workflow and see how it behaves.</p>
<LemonButton type="primary" onClick={() => setSelectedNodeId(TRIGGER_NODE_ID)} icon={<IconPlay />}>
<LemonButton type="primary" onClick={() => setSelectedNodeId(TRIGGER_NODE_ID)}>
Start testing
</LemonButton>
</div>
@@ -149,7 +149,6 @@ export function HogFlowEditorPanelTest(): JSX.Element | null {
type="primary"
data-attr="test-workflow-panel-new"
onClick={() => submitTestInvocation()}
icon={<IconPlay />}
loading={isTestInvocationSubmitting}
disabledReason={sampleGlobals ? undefined : 'Must load event to run test'}
size="small"

View File

@@ -259,10 +259,9 @@ function StepTriggerConfigurationManual(): JSX.Element {
<p className="mb-0">
This workflow can be triggered manually via{' '}
<Tooltip title="It's up there on the top right ⤴︎">
<span className="font-bold cursor-pointer">the trigger button</span>
<span className="font-bold cursor-pointer">the trigger button on the top</span>
</Tooltip>
</p>
<IconButton fontSize={24} />
</div>
</>
)

View File

@@ -123,7 +123,7 @@ export const workflowLogic = kea<workflowLogicType>([
// NOTE: This is a wrapper for setWorkflowValues, to get around some weird typegen issues
setWorkflowInfo: (workflow: Partial<HogFlow>) => ({ workflow }),
saveWorkflowPartial: (workflow: Partial<HogFlow>) => ({ workflow }),
triggerManualWorkflow: true,
triggerManualWorkflow: (variables: Record<string, any>) => ({ variables }),
discardChanges: true,
}),
loaders(({ props, values }) => ({
@@ -392,7 +392,7 @@ export const workflowLogic = kea<workflowLogicType>([
actions.setWorkflowValues({ edges: [...newEdges, ...edges] })
},
triggerManualWorkflow: async () => {
triggerManualWorkflow: async ({ variables }) => {
if (!values.workflow.id || values.workflow.id === 'new') {
lemonToast.error('You need to save the workflow before triggering it manually.')
return
@@ -409,13 +409,14 @@ export const workflowLogic = kea<workflowLogicType>([
'Content-Type': 'application/json',
},
body: JSON.stringify({
user_id: values.user?.id,
user_id: String(values.user?.id),
$variables: variables,
}),
credentials: 'omit',
})
} catch (e) {
lemonToast.error('Error triggering workflow: ' + (e as Error).message)
// return
return
}
lemonToast.success('Workflow triggered', {

View File

@@ -142,7 +142,7 @@ export function WorkflowsScene(): JSX.Element {
key: 'workflows',
content: (
<>
<p>Create automated workflows workflows triggered by events</p>
<p>Create and manage your workflows</p>
<WorkflowsTable />
</>
),