Add test tab to tool step (#19760)

So we can use those as variables.
When the function input is updated, invalidate the input using an
effect.

<img width="770" height="635" alt="Capture d’écran 2026-04-16 à 14 45
41"
src="https://github.com/user-attachments/assets/cbf0b3e4-baad-424d-8f08-06eb1028abdb"
/>
This commit is contained in:
Thomas Trompette
2026-04-16 16:00:21 +02:00
committed by GitHub
parent c0fef0be08
commit cd31d9e0de
5 changed files with 192 additions and 20 deletions
@@ -1,20 +1,38 @@
import { getFunctionInputFromInputSchema } from 'twenty-shared/workflow';
import { mergeDefaultFunctionInputAndFunctionInput } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/mergeDefaultFunctionInputAndFunctionInput';
import { LogicFunctionExecutionResult } from '@/logic-functions/components/LogicFunctionExecutionResult';
import { LogicFunctionLogs } from '@/logic-functions/components/LogicFunctionLogs';
import { LogicFunctionTestInputInitEffect } from '@/logic-functions/components/LogicFunctionTestInputInitEffect';
import { useExecuteLogicFunction } from '@/logic-functions/hooks/useExecuteLogicFunction';
import { useGetOneLogicFunction } from '@/logic-functions/hooks/useGetOneLogicFunction';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { type WorkflowLogicFunctionAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepCmdEnterButton } from '@/workflow/workflow-steps/components/WorkflowStepCmdEnterButton';
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
import { WorkflowEditActionCodeFields } from '@/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCodeFields';
import { mergeDefaultFunctionInputAndFunctionInput } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/mergeDefaultFunctionInputAndFunctionInput';
import { setNestedValue } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/setNestedValue';
import { WORKFLOW_LOGIC_FUNCTION_ACTION_TAB_LIST_COMPONENT_ID } from '@/workflow/workflow-steps/workflow-actions/logic-function-action/constants/WorkflowLogicFunctionActionTabListComponentId';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { isObject } from '@sniptt/guards';
import { useMemo } from 'react';
import { getOutputSchemaFromValue } from 'twenty-shared/logic-function';
import { isDefined } from 'twenty-shared/utils';
import { Callout } from 'twenty-ui/display';
import { useDebouncedCallback } from 'use-debounce';
import { getFunctionInputFromInputSchema } from 'twenty-shared/workflow';
import {
Callout,
IconPlayerPlay,
IconSettingsAutomation,
} from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useDebouncedCallback } from 'use-debounce';
const INPUT_TAB_ID = 'input';
const TEST_TAB_ID = 'test';
const StyledContainer = styled.div`
display: flex;
@@ -22,6 +40,17 @@ const StyledContainer = styled.div`
gap: ${themeCssVariables.spacing[4]};
`;
const StyledTabListContainer = styled.div`
background-color: ${themeCssVariables.background.secondary};
padding-left: ${themeCssVariables.spacing[2]};
`;
const StyledResultContainer = styled.div`
display: flex;
flex-direction: column;
position: relative;
`;
type WorkflowEditActionLogicFunctionProps = {
action: WorkflowLogicFunctionAction;
actionOptions:
@@ -46,6 +75,11 @@ export const WorkflowEditActionLogicFunction = ({
id: logicFunctionId,
});
const activeTabId = useAtomComponentStateValue(
activeTabIdComponentState,
WORKFLOW_LOGIC_FUNCTION_ACTION_TAB_LIST_COMPONENT_ID,
);
const functionInput = useMemo(() => {
const toolInputSchema = logicFunction?.toolInputSchema;
@@ -86,6 +120,34 @@ export const WorkflowEditActionLogicFunction = ({
500,
);
const updateOutputSchemaFromTestResult = (testResult: object) => {
if (actionOptions.readonly === true) {
return;
}
const newOutputSchema = getOutputSchemaFromValue(testResult);
updateAction({
...action,
settings: { ...action.settings, outputSchema: newOutputSchema },
});
};
const {
executeLogicFunction,
isExecuting,
logicFunctionTestData,
updateLogicFunctionInput,
} = useExecuteLogicFunction({
logicFunctionId,
callback: updateOutputSchemaFromTestResult,
});
const testInput = mergeDefaultFunctionInputAndFunctionInput({
newInput: functionInput,
oldInput: logicFunctionTestData.input,
});
const handleInputChange = (value: unknown, path: string[]) => {
const updatedFunctionInput = setNestedValue(functionInput, path, value);
@@ -100,34 +162,115 @@ export const WorkflowEditActionLogicFunction = ({
});
};
const handleTestInputChange = (value: unknown, path: string[]) => {
if (actionOptions.readonly === true) {
return;
}
const updatedTestFunctionInput = setNestedValue(testInput, path, value);
updateLogicFunctionInput(updatedTestFunctionInput);
};
const handleTestFunction = async () => {
if (actionOptions.readonly === true) {
return;
}
await executeLogicFunction();
};
if (loading) {
return null;
}
const hasInputFields = Object.keys(functionInput).length > 0;
const tabs = [
{
id: INPUT_TAB_ID,
title: t`Input`,
Icon: IconSettingsAutomation,
},
{
id: TEST_TAB_ID,
title: t`Test`,
Icon: IconPlayerPlay,
},
];
return (
<>
<LogicFunctionTestInputInitEffect logicFunctionId={logicFunctionId} />
<StyledTabListContainer>
<TabList
tabs={tabs}
behaveAsLinks={false}
componentInstanceId={
WORKFLOW_LOGIC_FUNCTION_ACTION_TAB_LIST_COMPONENT_ID
}
/>
</StyledTabListContainer>
<WorkflowStepBody>
<StyledContainer>
{hasInputFields ? (
{activeTabId === TEST_TAB_ID ? (
<>
<WorkflowEditActionCodeFields
functionInput={functionInput}
functionInput={testInput}
onInputChange={handleTestInputChange}
readonly={actionOptions.readonly}
onInputChange={handleInputChange}
VariablePicker={WorkflowVariablePicker}
fullWidth
/>
) : (
<Callout
variant={'neutral'}
title={t`No input fields for this action`}
description={t`You can see the function logic in your application settings.`}
/>
)}
</StyledContainer>
<StyledResultContainer>
<InputLabel>{t`Result`}</InputLabel>
<LogicFunctionExecutionResult
logicFunctionTestData={logicFunctionTestData}
isTesting={isExecuting}
/>
</StyledResultContainer>
{logicFunctionTestData.output.logs.length > 0 && (
<StyledResultContainer>
<LogicFunctionLogs
componentInstanceId={`workflow-edit-action-logs-${action.id}`}
value={isExecuting ? '' : logicFunctionTestData.output.logs}
/>
</StyledResultContainer>
)}
</>
) : (
<StyledContainer>
{hasInputFields ? (
<WorkflowEditActionCodeFields
functionInput={functionInput}
readonly={actionOptions.readonly}
onInputChange={handleInputChange}
VariablePicker={WorkflowVariablePicker}
fullWidth
/>
) : (
<Callout
variant={'neutral'}
title={t`No input fields for this action`}
description={t`You can see the function logic in your application settings.`}
/>
)}
</StyledContainer>
)}
</WorkflowStepBody>
{!actionOptions.readonly && <WorkflowStepFooter stepId={action.id} />}
{!actionOptions.readonly && (
<WorkflowStepFooter
stepId={action.id}
additionalActions={
activeTabId === TEST_TAB_ID
? [
<WorkflowStepCmdEnterButton
title={t`Test`}
onClick={handleTestFunction}
disabled={isExecuting}
/>,
]
: []
}
/>
)}
</>
);
};
@@ -0,0 +1,2 @@
export const WORKFLOW_LOGIC_FUNCTION_ACTION_TAB_LIST_COMPONENT_ID =
'workflow-logic-function-action-tab-list-component-id';
@@ -61,6 +61,15 @@ describe('computeStepOutputSchema', () => {
expect(result).toBeUndefined();
});
it('should return undefined for LOGIC_FUNCTION step type', () => {
const result = computeStepOutputSchema({
step: { type: 'LOGIC_FUNCTION', settings: {} } as any,
objectMetadataItems: [],
});
expect(result).toBeUndefined();
});
});
describe('DATABASE_EVENT trigger', () => {
@@ -466,6 +475,10 @@ describe('shouldComputeOutputSchemaOnFrontend', () => {
expect(shouldComputeOutputSchemaOnFrontend('ITERATOR')).toBe(false);
});
it('should return false for LOGIC_FUNCTION', () => {
expect(shouldComputeOutputSchemaOnFrontend('LOGIC_FUNCTION')).toBe(false);
});
it('should return true for DATABASE_EVENT', () => {
expect(shouldComputeOutputSchemaOnFrontend('DATABASE_EVENT')).toBe(true);
});
@@ -17,6 +17,7 @@ const PERSISTED_OUTPUT_SCHEMA_TYPES = [
'AI_AGENT',
'CODE',
'HTTP_REQUEST',
'LOGIC_FUNCTION',
'WEBHOOK',
'ITERATOR',
];
@@ -224,9 +224,22 @@ export class WorkflowVersionStepOperationsWorkspaceService {
type: WorkflowActionType.LOGIC_FUNCTION,
settings: {
...BASE_STEP_DEFINITION,
outputSchema: {
link: {
isLeaf: true,
icon: 'IconVariable',
tab: 'test',
label: 'Generate Function Output',
},
_outputSchemaType: 'LINK',
},
input: {
logicFunctionId,
logicFunctionInput: {},
logicFunctionInput: isDefined(flatLogicFunction.toolInputSchema)
? (getFunctionInputFromInputSchema([
flatLogicFunction.toolInputSchema,
])[0] ?? {})
: {},
},
},
},