mirror of
https://github.com/BillyOutlast/posthog.git
synced 2026-02-04 03:01:23 +01:00
feat(messaging): Testing area improvements (#37678)
This commit is contained in:
@@ -163,6 +163,7 @@ import {
|
||||
UserType,
|
||||
} from '~/types'
|
||||
|
||||
import { HogflowTestResult } from 'products/messaging/frontend/Campaigns/hogflows/steps/types'
|
||||
import { HogFlow } from 'products/messaging/frontend/Campaigns/hogflows/types'
|
||||
import { OptOutEntry } from 'products/messaging/frontend/OptOuts/optOutListLogic'
|
||||
import { MessageTemplate } from 'products/messaging/frontend/TemplateLibrary/messageTemplatesLogic'
|
||||
@@ -3920,7 +3921,7 @@ const api = {
|
||||
invocation_id?: string
|
||||
current_action_id?: string
|
||||
}
|
||||
): Promise<any> {
|
||||
): Promise<HogflowTestResult> {
|
||||
return await new ApiRequest().hogFlow(hogFlowId).withAction('invocations').create({ data })
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
LemonSelect,
|
||||
LemonTable,
|
||||
LemonTableColumn,
|
||||
LemonTableProps,
|
||||
LemonTag,
|
||||
LemonTagProps,
|
||||
Link,
|
||||
@@ -59,6 +60,7 @@ export type LogsViewerProps = LogsViewerLogicProps & {
|
||||
/**
|
||||
* NOTE: There is a loose attempt to keeep this generic so we can use it as an abstract log component in the future.
|
||||
*/
|
||||
|
||||
export function LogsViewer({
|
||||
renderColumns = (c) => c,
|
||||
renderMessage = (m) => m,
|
||||
@@ -80,49 +82,6 @@ export function LogsViewer({
|
||||
} = useValues(logic)
|
||||
const { revealHiddenLogs, loadOlderLogs, setFilters, setRowExpanded, setIsGrouped } = useActions(logic)
|
||||
|
||||
const logColumns: LemonTableColumn<LogEntry, keyof LogEntry | undefined>[] = [
|
||||
{
|
||||
title: 'Timestamp',
|
||||
key: 'timestamp',
|
||||
dataIndex: 'timestamp',
|
||||
width: 0,
|
||||
render: (_, { timestamp }) => <TZLabel time={timestamp} />,
|
||||
},
|
||||
{
|
||||
title: 'Level',
|
||||
key: 'level',
|
||||
dataIndex: 'level',
|
||||
width: 0,
|
||||
render: (_, { level }) => <LemonTag type={tagTypeForLevel(level)}>{level.toUpperCase()}</LemonTag>,
|
||||
},
|
||||
{
|
||||
width: 0,
|
||||
title: instanceLabel,
|
||||
dataIndex: 'instanceId',
|
||||
key: 'instanceId',
|
||||
render: (_, { instanceId }) => (
|
||||
<code className="whitespace-nowrap">
|
||||
<CopyToClipboardInline explicitValue={instanceId} selectable>
|
||||
<Link
|
||||
className="font-semibold"
|
||||
subtle
|
||||
onClick={() => setRowExpanded(instanceId, !expandedRows[instanceId])}
|
||||
title={instanceId}
|
||||
>
|
||||
{shortInstanceId(instanceId)}
|
||||
</Link>
|
||||
</CopyToClipboardInline>
|
||||
</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Message',
|
||||
key: 'message',
|
||||
dataIndex: 'message',
|
||||
render: (_, { message }) => <code className="whitespace-pre-wrap">{renderMessage(message)}</code>,
|
||||
},
|
||||
]
|
||||
|
||||
const groupedLogColumns: LemonTableColumn<GroupedLogEntry, keyof GroupedLogEntry | undefined>[] = renderColumns([
|
||||
{
|
||||
title: 'Timestamp',
|
||||
@@ -304,16 +263,18 @@ export function LogsViewer({
|
||||
onRowCollapse: (record) => setRowExpanded(record.instanceId, false),
|
||||
expandedRowRender: (record) => {
|
||||
return (
|
||||
<LemonTable
|
||||
<LogsViewerTable
|
||||
instanceLabel={instanceLabel}
|
||||
renderMessage={renderMessage}
|
||||
embedded
|
||||
dataSource={record.entries}
|
||||
columns={[
|
||||
renderColumns={(columns) => [
|
||||
{
|
||||
key: 'spacer',
|
||||
width: 0,
|
||||
render: () => <div className="w-6" />,
|
||||
},
|
||||
...logColumns,
|
||||
...columns,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
@@ -321,13 +282,14 @@ export function LogsViewer({
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<LemonTable
|
||||
<LogsViewerTable
|
||||
instanceLabel={instanceLabel}
|
||||
renderMessage={renderMessage}
|
||||
key="ungrouped"
|
||||
dataSource={unGroupedLogs}
|
||||
loading={logsLoading}
|
||||
className="ph-no-capture overflow-y-auto"
|
||||
rowKey={(record, index) => `${record.timestamp.toISOString()}-${index}`}
|
||||
columns={logColumns}
|
||||
footer={footer}
|
||||
/>
|
||||
)}
|
||||
@@ -336,3 +298,62 @@ export function LogsViewer({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LogsViewerTable({
|
||||
instanceLabel,
|
||||
renderMessage,
|
||||
renderColumns = (c) => c,
|
||||
...props
|
||||
}: Omit<LemonTableProps<LogEntry>, 'columns'> & {
|
||||
instanceLabel: string
|
||||
renderMessage: (message: string) => JSX.Element | string
|
||||
renderColumns?: (
|
||||
columns: LemonTableColumn<LogEntry, keyof LogEntry | undefined>[]
|
||||
) => LemonTableColumn<LogEntry, keyof LogEntry | undefined>[]
|
||||
}): JSX.Element {
|
||||
let logColumns: LemonTableColumn<LogEntry, keyof LogEntry | undefined>[] = [
|
||||
{
|
||||
title: 'Timestamp',
|
||||
key: 'timestamp',
|
||||
dataIndex: 'timestamp',
|
||||
width: 0,
|
||||
render: (_, { timestamp }) => <TZLabel time={timestamp} />,
|
||||
},
|
||||
{
|
||||
title: 'Level',
|
||||
key: 'level',
|
||||
dataIndex: 'level',
|
||||
width: 0,
|
||||
render: (_, { level }) => <LemonTag type={tagTypeForLevel(level)}>{level.toUpperCase()}</LemonTag>,
|
||||
},
|
||||
{
|
||||
width: 0,
|
||||
title: instanceLabel,
|
||||
dataIndex: 'instanceId',
|
||||
key: 'instanceId',
|
||||
render: (_, { instanceId }) => (
|
||||
<code className="whitespace-nowrap">
|
||||
<CopyToClipboardInline explicitValue={instanceId} selectable>
|
||||
<Link className="font-semibold" subtle title={instanceId}>
|
||||
{shortInstanceId(instanceId)}
|
||||
</Link>
|
||||
</CopyToClipboardInline>
|
||||
</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Message',
|
||||
key: 'message',
|
||||
dataIndex: 'message',
|
||||
render: (_, { message }) => <code className="whitespace-pre-wrap">{renderMessage(message)}</code>,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<LemonTable
|
||||
{...props}
|
||||
rowKey={(record, index) => `${record.timestamp.toISOString()}-${index}`}
|
||||
columns={renderColumns(logColumns)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -480,7 +480,7 @@ export class CdpApi {
|
||||
const result = await this.hogFlowExecutor.executeCurrentAction(invocation)
|
||||
|
||||
res.json({
|
||||
result,
|
||||
nextActionId: result.invocation.state.currentAction?.id,
|
||||
status: result.error ? 'error' : 'success',
|
||||
errors: result.error ? [result.error] : [],
|
||||
logs: result.logs,
|
||||
|
||||
@@ -223,7 +223,7 @@ describe('Hogflow Executor', () => {
|
||||
{
|
||||
level: 'info',
|
||||
timestamp: expect.any(DateTime),
|
||||
message: "Workflow moved to action 'exit (exit)'",
|
||||
message: 'Workflow moved to action [Action:exit]',
|
||||
},
|
||||
{
|
||||
level: 'info',
|
||||
@@ -297,7 +297,7 @@ describe('Hogflow Executor', () => {
|
||||
"[Action:function_id_1] Fetch 3, 200",
|
||||
"[Action:function_id_1] All fetches done!",
|
||||
"[Action:function_id_1] Function completed in REPLACEDms. Sync: 0ms. Mem: 0.099kb. Ops: 32. Event: 'http://localhost:8000/events/1'",
|
||||
"Workflow moved to action 'exit (exit)'",
|
||||
"Workflow moved to action [Action:exit]",
|
||||
"Workflow completed",
|
||||
]
|
||||
`)
|
||||
@@ -379,7 +379,7 @@ describe('Hogflow Executor', () => {
|
||||
expect(result1.finished).toBe(false)
|
||||
expect(result1.invocation.state.currentAction?.id).toBe('function_id_1')
|
||||
expect(result1.logs.map((log) => log.message)).toEqual([
|
||||
"Workflow moved to action 'function (function_id_1)'",
|
||||
'Workflow moved to action [Action:function_id_1]',
|
||||
])
|
||||
|
||||
// Second step: should process function_id_1 and move to exit, but not complete
|
||||
@@ -390,7 +390,7 @@ describe('Hogflow Executor', () => {
|
||||
'[Action:function_id_1] Hello, Mr Debug User!',
|
||||
'[Action:function_id_1] Fetch 1, 200',
|
||||
expect.stringContaining('[Action:function_id_1] Function completed in'),
|
||||
"Workflow moved to action 'exit (exit)'",
|
||||
'Workflow moved to action [Action:exit]',
|
||||
'Workflow completed',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -31,6 +31,11 @@ import { ensureCurrentAction, findContinueAction, shouldSkipAction } from './hog
|
||||
|
||||
export const MAX_ACTION_STEPS_HARD_LIMIT = 1000
|
||||
|
||||
// Special format which the frontend understands and can render as a link
|
||||
const actionIdForLogging = (action: HogFlowAction) => {
|
||||
return `[Action:${action.id}]`
|
||||
}
|
||||
|
||||
export class HogFlowExecutorService {
|
||||
private readonly actionHandlers: Record<HogFlowAction['type'], ActionHandler>
|
||||
|
||||
@@ -324,7 +329,7 @@ export class HogFlowExecutorService {
|
||||
result.logs.push({
|
||||
level: 'info',
|
||||
timestamp: DateTime.now(),
|
||||
message: `Workflow moved to action '${nextAction.name} (${nextAction.id})'`,
|
||||
message: `Workflow moved to action ${actionIdForLogging(nextAction)}`,
|
||||
})
|
||||
|
||||
this.trackActionMetric(result, currentAction, reason === 'filtered' ? 'filtered' : 'succeeded')
|
||||
@@ -357,7 +362,7 @@ export class HogFlowExecutorService {
|
||||
level: LogEntryLevel,
|
||||
message: string
|
||||
): void {
|
||||
this.log(result, level, `[Action:${action.id}] ${message}`)
|
||||
this.log(result, level, `${actionIdForLogging(action)} ${message}`)
|
||||
}
|
||||
|
||||
private log(
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { Form } from 'kea-forms'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { IconInfo, IconPlay, IconPlayFilled, IconRedo } from '@posthog/icons'
|
||||
import {
|
||||
LemonBanner,
|
||||
LemonButton,
|
||||
LemonCollapse,
|
||||
LemonDivider,
|
||||
LemonLabel,
|
||||
LemonSwitch,
|
||||
LemonTable,
|
||||
Link,
|
||||
ProfilePicture,
|
||||
Spinner,
|
||||
Tooltip,
|
||||
} from '@posthog/lemon-ui'
|
||||
|
||||
import { TZLabel } from 'lib/components/TZLabel'
|
||||
import { LemonField } from 'lib/lemon-ui/LemonField'
|
||||
import { LogsViewerTable } from 'scenes/hog-functions/logs/LogsViewer'
|
||||
import { asDisplay } from 'scenes/persons/person-utils'
|
||||
import { urls } from 'scenes/urls'
|
||||
|
||||
import { campaignLogic } from '../../../campaignLogic'
|
||||
import { renderWorkflowLogMessage } from '../../../logs/log-utils'
|
||||
import { hogFlowEditorLogic } from '../../hogFlowEditorLogic'
|
||||
import { hogFlowEditorTestLogic } from './hogFlowEditorTestLogic'
|
||||
|
||||
@@ -34,20 +38,26 @@ export function HogFlowTestPanelNonSelected(): JSX.Element {
|
||||
}
|
||||
|
||||
export function HogFlowEditorPanelTest(): JSX.Element | null {
|
||||
const { selectedNode } = useValues(hogFlowEditorLogic)
|
||||
const { campaign, selectedNode } = useValues(hogFlowEditorLogic)
|
||||
const { setSelectedNodeId } = useActions(hogFlowEditorLogic)
|
||||
const { logicProps } = useValues(campaignLogic)
|
||||
const { sampleGlobals, isTestInvocationSubmitting, testResult, shouldLoadSampleGlobals } = useValues(
|
||||
hogFlowEditorTestLogic(logicProps)
|
||||
)
|
||||
|
||||
const {
|
||||
sampleGlobals,
|
||||
sampleGlobalsLoading,
|
||||
isTestInvocationSubmitting,
|
||||
testResult,
|
||||
shouldLoadSampleGlobals,
|
||||
nextActionId,
|
||||
} = useValues(hogFlowEditorTestLogic(logicProps))
|
||||
const { submitTestInvocation, setTestResult, loadSampleGlobals } = useActions(hogFlowEditorTestLogic(logicProps))
|
||||
|
||||
const display = asDisplay(sampleGlobals?.person)
|
||||
const url = urls.personByDistinctId(sampleGlobals?.event?.distinct_id || '')
|
||||
|
||||
if (!selectedNode) {
|
||||
// NOTE: This shouldn't ever happen as the parent checks it
|
||||
return null
|
||||
}
|
||||
useEffect(() => {
|
||||
setTestResult(null)
|
||||
}, [selectedNode?.id, setTestResult])
|
||||
|
||||
return (
|
||||
<Form
|
||||
@@ -57,178 +67,194 @@ export function HogFlowEditorPanelTest(): JSX.Element | null {
|
||||
enableFormOnSubmit
|
||||
className="flex overflow-hidden flex-col flex-1"
|
||||
>
|
||||
{/* Body */}
|
||||
<div className="flex overflow-y-auto flex-col flex-1 gap-2 p-2">
|
||||
{/* Event Information */}
|
||||
{sampleGlobals?.event && (
|
||||
<div className="p-3 rounded border bg-surface-secondary">
|
||||
<div className="flex flex-wrap gap-1 items-center">
|
||||
{sampleGlobals.person && (
|
||||
<Link to={url} className="flex gap-2 items-center">
|
||||
<ProfilePicture name={display} /> <span className="font-semibold">{display}</span>
|
||||
</Link>
|
||||
)}
|
||||
<span className="text-muted">performed</span>
|
||||
<div className="space-y-1 font-semibold text-md">{sampleGlobals.event.event}</div>{' '}
|
||||
<div>
|
||||
<TZLabel time={sampleGlobals.event.timestamp} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Event Properties */}
|
||||
{sampleGlobals.event.properties && Object.keys(sampleGlobals.event.properties).length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="mb-2 text-sm">Event properties</div>
|
||||
<div className="overflow-auto max-h-32 rounded border bg-surface-primary">
|
||||
<pre className="p-2 text-xs whitespace-pre-wrap text-muted">
|
||||
{JSON.stringify(sampleGlobals.event.properties, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Test Results */}
|
||||
{testResult && (
|
||||
<div data-attr="test-results" className="flex flex-col gap-2">
|
||||
<h2 className="mb-0">Test results</h2>
|
||||
<LemonBanner
|
||||
type={
|
||||
testResult.status === 'success'
|
||||
? 'success'
|
||||
: testResult.status === 'skipped'
|
||||
? 'warning'
|
||||
: 'error'
|
||||
<div className="flex gap-2 items-center p-2">
|
||||
<LemonField name="mock_async_functions" className="flex-1">
|
||||
{({ value, onChange }) => (
|
||||
<LemonSwitch
|
||||
onChange={(v) => onChange(!v)}
|
||||
checked={!value}
|
||||
data-attr="toggle-workflow-test-panel-new-mocking"
|
||||
className="whitespace-nowrap"
|
||||
size="small"
|
||||
bordered
|
||||
label={
|
||||
<Tooltip
|
||||
title={
|
||||
<>
|
||||
When disabled, message deliveries and other async actions will not be
|
||||
called. Instead they will be mocked out and logged.
|
||||
</>
|
||||
}
|
||||
>
|
||||
<span className="flex gap-2">
|
||||
Make real HTTP requests
|
||||
<IconInfo className="text-lg" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{testResult.status === 'success'
|
||||
? 'Success'
|
||||
: testResult.status === 'skipped'
|
||||
? 'Workflow was skipped because the event did not match the filter criteria'
|
||||
: 'Error'}
|
||||
</LemonBanner>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<LemonLabel>Test invocation logs</LemonLabel>
|
||||
|
||||
<LemonTable
|
||||
dataSource={testResult.logs ?? []}
|
||||
columns={[
|
||||
{
|
||||
title: 'Timestamp',
|
||||
key: 'timestamp',
|
||||
dataIndex: 'timestamp',
|
||||
render: (timestamp) => <TZLabel time={timestamp as string} />,
|
||||
width: 0,
|
||||
},
|
||||
{
|
||||
width: 100,
|
||||
title: 'Level',
|
||||
key: 'level',
|
||||
dataIndex: 'level',
|
||||
},
|
||||
{
|
||||
title: 'Message',
|
||||
key: 'message',
|
||||
dataIndex: 'message',
|
||||
render: (message) => <code className="whitespace-pre-wrap">{message}</code>,
|
||||
},
|
||||
]}
|
||||
className="ph-no-capture"
|
||||
rowKey="timestamp"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<LemonDivider className="my-0" />
|
||||
{/* footer */}
|
||||
<div className="p-2">
|
||||
/>
|
||||
)}
|
||||
</LemonField>
|
||||
{testResult ? (
|
||||
<div className="flex justify-end gap-2">
|
||||
<>
|
||||
<div className="flex-1" />
|
||||
<LemonButton
|
||||
type="secondary"
|
||||
onClick={() => setTestResult(null)}
|
||||
loading={isTestInvocationSubmitting}
|
||||
size="small"
|
||||
data-attr="clear-workflow-test-panel-new-result"
|
||||
>
|
||||
Clear test result
|
||||
</LemonButton>
|
||||
|
||||
{selectedNode?.data?.type !== 'exit' && (
|
||||
{nextActionId && (
|
||||
<LemonButton
|
||||
type="primary"
|
||||
onClick={() => submitTestInvocation()}
|
||||
onClick={() => setSelectedNodeId(nextActionId)}
|
||||
icon={<IconPlayFilled />}
|
||||
loading={isTestInvocationSubmitting}
|
||||
size="small"
|
||||
data-attr="continue-workflow-test-panel-new"
|
||||
>
|
||||
Continue
|
||||
Go to next step
|
||||
</LemonButton>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-sm text-muted">
|
||||
Note: Delays will be logged to indicate when they would have been executed.
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
<LemonField name="mock_async_functions" className="flex-1">
|
||||
{({ value, onChange }) => (
|
||||
<LemonSwitch
|
||||
onChange={(v) => onChange(!v)}
|
||||
checked={!value}
|
||||
data-attr="toggle-workflow-test-panel-new-mocking"
|
||||
label={
|
||||
<Tooltip
|
||||
title={
|
||||
<>
|
||||
When disabled, message deliveries and other async actions
|
||||
will not be called. Instead they will be mocked out and
|
||||
logged.
|
||||
</>
|
||||
}
|
||||
>
|
||||
<span className="flex gap-2">
|
||||
Make real HTTP requests
|
||||
<IconInfo className="text-lg" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</LemonField>
|
||||
|
||||
<LemonButton
|
||||
type="secondary"
|
||||
onClick={() => loadSampleGlobals()}
|
||||
tooltip="Find the last event matching the trigger event filters, and use it to populate the globals for a test run."
|
||||
disabledReason={
|
||||
!shouldLoadSampleGlobals ? 'Must configure trigger event' : undefined
|
||||
}
|
||||
icon={<IconRedo />}
|
||||
>
|
||||
Load new event
|
||||
</LemonButton>
|
||||
|
||||
<LemonButton
|
||||
type="primary"
|
||||
data-attr="test-workflow-panel-new"
|
||||
onClick={() => submitTestInvocation()}
|
||||
icon={<IconPlay />}
|
||||
loading={isTestInvocationSubmitting}
|
||||
disabledReason={sampleGlobals ? undefined : 'Must load event to run test'}
|
||||
>
|
||||
Run test
|
||||
</LemonButton>
|
||||
</div>
|
||||
</div>
|
||||
<LemonButton
|
||||
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"
|
||||
>
|
||||
Run test
|
||||
</LemonButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<LemonDivider className="my-0" />
|
||||
<div className="flex flex-col flex-1 overflow-y-auto">
|
||||
{/* Event Information */}
|
||||
<div className="flex-0">
|
||||
<LemonCollapse
|
||||
embedded
|
||||
panels={[
|
||||
{
|
||||
key: 'event',
|
||||
header: {
|
||||
children: sampleGlobalsLoading ? (
|
||||
<>
|
||||
Loading test event... <Spinner />
|
||||
</>
|
||||
) : (
|
||||
<>Test event: {sampleGlobals?.event?.event} </>
|
||||
),
|
||||
},
|
||||
className: 'bg-surface-secondary',
|
||||
content: (
|
||||
<div>
|
||||
<div className="bg-surface-secondary">
|
||||
<div className="flex gap-2 items-center">
|
||||
<ProfilePicture name={display} />
|
||||
<div className="flex-1">
|
||||
{sampleGlobals?.person ? (
|
||||
<Link to={url} className="flex gap-2 items-center">
|
||||
<span className="font-semibold">{display}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted">Loading...</span>
|
||||
)}{' '}
|
||||
<span className="text-muted">performed</span>{' '}
|
||||
<span className="space-y-1 font-semibold text-md">
|
||||
{sampleGlobals?.event.event}
|
||||
</span>{' '}
|
||||
{sampleGlobals?.event.timestamp && (
|
||||
<TZLabel time={sampleGlobals.event.timestamp} />
|
||||
)}
|
||||
</div>
|
||||
<LemonButton
|
||||
type="secondary"
|
||||
onClick={() => loadSampleGlobals()}
|
||||
tooltip="Find the last event matching the trigger event filters, and use it to populate the globals for a test run."
|
||||
disabledReason={
|
||||
!shouldLoadSampleGlobals
|
||||
? 'Must configure trigger event'
|
||||
: undefined
|
||||
}
|
||||
icon={<IconRedo />}
|
||||
size="small"
|
||||
>
|
||||
Load new event
|
||||
</LemonButton>
|
||||
</div>
|
||||
|
||||
{/* Event Properties */}
|
||||
{sampleGlobals?.event.properties &&
|
||||
Object.keys(sampleGlobals.event.properties).length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="mb-2 text-sm">Event properties</div>
|
||||
<div className="overflow-auto max-h-32 rounded border bg-surface-primary">
|
||||
<pre className="p-2 text-xs whitespace-pre-wrap text-muted">
|
||||
{JSON.stringify(
|
||||
sampleGlobals.event.properties,
|
||||
null,
|
||||
2
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<LemonDivider className="my-0" />
|
||||
<div className="flex flex-col flex-1 gap-2 p-2">
|
||||
<h3 className="mb-0">Test results</h3>
|
||||
{!testResult ? (
|
||||
<div className="text-muted text-sm">No tests run yet</div>
|
||||
) : (
|
||||
<>
|
||||
<LemonBanner
|
||||
type={
|
||||
testResult.status === 'success'
|
||||
? 'success'
|
||||
: testResult.status === 'skipped'
|
||||
? 'warning'
|
||||
: 'error'
|
||||
}
|
||||
>
|
||||
{testResult.status === 'success'
|
||||
? 'Success'
|
||||
: testResult.status === 'skipped'
|
||||
? 'Workflow was skipped because the event did not match the filter criteria'
|
||||
: 'Error: ' + testResult.errors?.join(', ')}
|
||||
</LemonBanner>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<LemonLabel>Logs</LemonLabel>
|
||||
|
||||
<LogsViewerTable
|
||||
instanceLabel="workflow run"
|
||||
renderMessage={(m) => renderWorkflowLogMessage(campaign, m)}
|
||||
dataSource={testResult.logs ?? []}
|
||||
renderColumns={(columns) => columns.filter((column) => column.key !== 'instanceId')}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
|
||||
import { CampaignLogicProps, campaignLogic } from '../../../campaignLogic'
|
||||
import { hogFlowEditorLogic } from '../../hogFlowEditorLogic'
|
||||
import { HogflowTestResult } from '../../steps/types'
|
||||
import { HogFlow } from '../../types'
|
||||
import type { hogFlowEditorTestLogicType } from './hogFlowEditorTestLogicType'
|
||||
|
||||
@@ -31,16 +32,6 @@ export interface HogflowTestInvocation {
|
||||
mock_async_functions: boolean
|
||||
}
|
||||
|
||||
export interface HogflowTestResult {
|
||||
status: 'success' | 'error' | 'skipped'
|
||||
result?: any
|
||||
logs?: Array<{
|
||||
timestamp: string
|
||||
level: string
|
||||
message: string
|
||||
}>
|
||||
}
|
||||
|
||||
export const hogFlowEditorTestLogic = kea<hogFlowEditorTestLogicType>([
|
||||
path((key) => ['products', 'messaging', 'frontend', 'Campaigns', 'hogflows', 'actions', 'workflowTestLogic', key]),
|
||||
props({} as CampaignLogicProps),
|
||||
@@ -56,6 +47,7 @@ export const hogFlowEditorTestLogic = kea<hogFlowEditorTestLogicType>([
|
||||
setSampleGlobalsError: (error: string | null) => ({ error }),
|
||||
cancelSampleGlobalsLoading: true,
|
||||
receiveExampleGlobals: (globals: object | null) => ({ globals }),
|
||||
setNextActionId: (nextActionId: string | null) => ({ nextActionId }),
|
||||
}),
|
||||
reducers({
|
||||
testResult: [
|
||||
@@ -84,6 +76,12 @@ export const hogFlowEditorTestLogic = kea<hogFlowEditorTestLogicType>([
|
||||
cancelSampleGlobalsLoading: () => true,
|
||||
},
|
||||
],
|
||||
nextActionId: [
|
||||
null as string | null,
|
||||
{
|
||||
setNextActionId: (_, { nextActionId }) => nextActionId,
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
loaders(({ actions, values }) => ({
|
||||
@@ -254,8 +252,20 @@ export const hogFlowEditorTestLogic = kea<hogFlowEditorTestLogicType>([
|
||||
current_action_id: values.selectedNodeId ?? undefined,
|
||||
})
|
||||
|
||||
actions.setTestResult(apiResponse)
|
||||
actions.setSelectedNodeId(values.testResult?.result?.invocation?.state?.currentAction?.id)
|
||||
const result: HogflowTestResult = {
|
||||
...apiResponse,
|
||||
logs: apiResponse.logs?.map((log) => ({
|
||||
...log,
|
||||
instanceId: 'test',
|
||||
timestamp: dayjs(log.timestamp),
|
||||
})),
|
||||
}
|
||||
|
||||
actions.setTestResult(result)
|
||||
const nextActionId = result.nextActionId
|
||||
if (nextActionId && nextActionId !== values.selectedNodeId) {
|
||||
actions.setNextActionId(nextActionId)
|
||||
}
|
||||
|
||||
return values.testInvocation
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Handle, NodeProps } from '@xyflow/react'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { LogEntry } from 'scenes/hog-functions/logs/logsViewerLogic'
|
||||
|
||||
import { Optional } from '~/types'
|
||||
|
||||
import { HogFlowAction } from '../types'
|
||||
@@ -159,3 +161,10 @@ export const isFunctionAction = (
|
||||
): action is Extract<HogFlowAction, { type: 'function' | 'function_sms' | 'function_email' }> => {
|
||||
return ['function', 'function_sms', 'function_email'].includes(action.type)
|
||||
}
|
||||
|
||||
export interface HogflowTestResult {
|
||||
status: 'success' | 'error' | 'skipped'
|
||||
logs?: LogEntry[]
|
||||
nextActionId: string | null
|
||||
errors?: string[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user