Serverless function follow up (#9924)
- remove asynchronous serverless function build - build serverless function synchronously instead on activate workflow or execute - add a loader on workflow code step test tab test button - add a new `ServerlessFunctionSyncStatus` `BUILDING` - add a new route to build a serverless function draft version - delay artificially execution to avoid UI flashing https://github.com/user-attachments/assets/8d958d9a-ef41-4261-999e-6ea374191e33
This commit is contained in:
@ -9,9 +9,11 @@ import { FeatureFlagKey } from '~/generated/graphql';
|
||||
export const CmdEnterActionButton = ({
|
||||
title,
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: {
|
||||
title: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
const isCommandMenuV2Enabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IsCommandMenuV2Enabled,
|
||||
@ -32,6 +34,7 @@ export const CmdEnterActionButton = ({
|
||||
accent="blue"
|
||||
size="medium"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
hotkeys={[getOsControlSymbol(), '⏎']}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -1,15 +1,16 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { LightCopyIconButton } from '@/object-record/record-field/components/LightCopyIconButton';
|
||||
import {
|
||||
DEFAULT_OUTPUT_VALUE,
|
||||
ServerlessFunctionTestData,
|
||||
} from '@/workflow/states/serverlessFunctionTestDataFamilyState';
|
||||
import { ServerlessFunctionTestData } from '@/workflow/states/serverlessFunctionTestDataFamilyState';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import {
|
||||
CodeEditor,
|
||||
CoreEditorHeader,
|
||||
IconSquareRoundedCheck,
|
||||
IconSquareRoundedX,
|
||||
IconLoader,
|
||||
IconSettings,
|
||||
AnimatedCircleLoading,
|
||||
} from 'twenty-ui';
|
||||
import { ServerlessFunctionExecutionStatus } from '~/generated-metadata/graphql';
|
||||
|
||||
@ -18,20 +19,33 @@ const StyledContainer = styled.div`
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const StyledOutput = styled.div<{ status?: ServerlessFunctionExecutionStatus }>`
|
||||
type OutputAccent = 'default' | 'success' | 'error';
|
||||
|
||||
const StyledInfoContainer = styled.div`
|
||||
display: flex;
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
`;
|
||||
|
||||
const StyledOutput = styled.div<{ accent?: OutputAccent }>`
|
||||
align-items: center;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
color: ${({ theme, status }) =>
|
||||
status === ServerlessFunctionExecutionStatus.SUCCESS
|
||||
color: ${({ theme, accent }) =>
|
||||
accent === 'success'
|
||||
? theme.color.turquoise
|
||||
: theme.color.red};
|
||||
: accent === 'error'
|
||||
? theme.color.red
|
||||
: theme.font.color.secondary};
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
export const ServerlessFunctionExecutionResult = ({
|
||||
serverlessFunctionTestData,
|
||||
isTesting = false,
|
||||
isBuilding = false,
|
||||
}: {
|
||||
serverlessFunctionTestData: ServerlessFunctionTestData;
|
||||
isTesting?: boolean;
|
||||
isBuilding?: boolean;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
@ -40,25 +54,60 @@ export const ServerlessFunctionExecutionResult = ({
|
||||
serverlessFunctionTestData.output.error ||
|
||||
'';
|
||||
|
||||
const leftNode =
|
||||
serverlessFunctionTestData.output.data === DEFAULT_OUTPUT_VALUE ? (
|
||||
'Output'
|
||||
) : (
|
||||
<StyledOutput status={serverlessFunctionTestData.output.status}>
|
||||
<IconSquareRoundedCheck size={theme.icon.size.md} />
|
||||
{serverlessFunctionTestData.output.status ===
|
||||
ServerlessFunctionExecutionStatus.SUCCESS
|
||||
? '200 OK'
|
||||
: '500 Error'}
|
||||
{' - '}
|
||||
{serverlessFunctionTestData.output.duration}ms
|
||||
</StyledOutput>
|
||||
);
|
||||
const SuccessLeftNode = (
|
||||
<StyledOutput accent="success">
|
||||
<IconSquareRoundedCheck size={theme.icon.size.md} />
|
||||
200 OK - {serverlessFunctionTestData.output.duration}ms
|
||||
</StyledOutput>
|
||||
);
|
||||
|
||||
const ErrorLeftNode = (
|
||||
<StyledOutput accent="error">
|
||||
<IconSquareRoundedX size={theme.icon.size.md} />
|
||||
500 Error - {serverlessFunctionTestData.output.duration}ms
|
||||
</StyledOutput>
|
||||
);
|
||||
|
||||
const IdleLeftNode = 'Output';
|
||||
|
||||
const PendingLeftNode = (isTesting || isBuilding) && (
|
||||
<StyledOutput>
|
||||
<AnimatedCircleLoading>
|
||||
{isTesting ? (
|
||||
<IconLoader size={theme.icon.size.md} />
|
||||
) : (
|
||||
<IconSettings size={theme.icon.size.md} />
|
||||
)}
|
||||
</AnimatedCircleLoading>
|
||||
<StyledInfoContainer>
|
||||
{isTesting ? 'Running function' : 'Building function'}
|
||||
</StyledInfoContainer>
|
||||
</StyledOutput>
|
||||
);
|
||||
|
||||
const computeLeftNode = () => {
|
||||
if (isTesting || isBuilding) {
|
||||
return PendingLeftNode;
|
||||
}
|
||||
if (
|
||||
serverlessFunctionTestData.output.status ===
|
||||
ServerlessFunctionExecutionStatus.ERROR
|
||||
) {
|
||||
return ErrorLeftNode;
|
||||
}
|
||||
if (
|
||||
serverlessFunctionTestData.output.status ===
|
||||
ServerlessFunctionExecutionStatus.SUCCESS
|
||||
) {
|
||||
return SuccessLeftNode;
|
||||
}
|
||||
return IdleLeftNode;
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<CoreEditorHeader
|
||||
leftNodes={[leftNode]}
|
||||
leftNodes={[computeLeftNode()]}
|
||||
rightNodes={[<LightCopyIconButton copyText={result} />]}
|
||||
/>
|
||||
<CodeEditor
|
||||
@ -66,6 +115,7 @@ export const ServerlessFunctionExecutionResult = ({
|
||||
language={serverlessFunctionTestData.language}
|
||||
height={serverlessFunctionTestData.height}
|
||||
options={{ readOnly: true, domReadOnly: true }}
|
||||
isLoading={isTesting || isBuilding}
|
||||
withHeader
|
||||
/>
|
||||
</StyledContainer>
|
||||
|
||||
@ -2,55 +2,76 @@ import { useExecuteOneServerlessFunction } from '@/settings/serverless-functions
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { serverlessFunctionTestDataFamilyState } from '@/workflow/states/serverlessFunctionTestDataFamilyState';
|
||||
import { isDefined } from 'twenty-ui';
|
||||
import { useState } from 'react';
|
||||
import { useBuildDraftServerlessFunction } from '@/settings/serverless-functions/hooks/useBuildDraftServerlessFunction';
|
||||
import { sleep } from '~/utils/sleep';
|
||||
|
||||
export const useTestServerlessFunction = ({
|
||||
serverlessFunctionId,
|
||||
serverlessFunctionVersion = 'draft',
|
||||
callback,
|
||||
}: {
|
||||
serverlessFunctionId: string;
|
||||
serverlessFunctionVersion?: string;
|
||||
callback?: (testResult: object) => void;
|
||||
}) => {
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [isBuilding, setIsBuilding] = useState(false);
|
||||
const { executeOneServerlessFunction } = useExecuteOneServerlessFunction();
|
||||
const { buildDraftServerlessFunction } = useBuildDraftServerlessFunction();
|
||||
const [serverlessFunctionTestData, setServerlessFunctionTestData] =
|
||||
useRecoilState(serverlessFunctionTestDataFamilyState(serverlessFunctionId));
|
||||
|
||||
const testServerlessFunction = async () => {
|
||||
const result = await executeOneServerlessFunction({
|
||||
id: serverlessFunctionId,
|
||||
payload: serverlessFunctionTestData.input,
|
||||
version: serverlessFunctionVersion,
|
||||
});
|
||||
const testServerlessFunction = async (shouldBuild = true) => {
|
||||
try {
|
||||
if (shouldBuild) {
|
||||
setIsBuilding(true);
|
||||
await buildDraftServerlessFunction({
|
||||
id: serverlessFunctionId,
|
||||
});
|
||||
setIsBuilding(false);
|
||||
}
|
||||
setIsTesting(true);
|
||||
await sleep(200); // Delay artificially to avoid flashing the UI
|
||||
const result = await executeOneServerlessFunction({
|
||||
id: serverlessFunctionId,
|
||||
payload: serverlessFunctionTestData.input,
|
||||
version: 'draft',
|
||||
});
|
||||
|
||||
if (isDefined(result?.data?.executeOneServerlessFunction?.data)) {
|
||||
callback?.(result?.data?.executeOneServerlessFunction?.data);
|
||||
setIsTesting(false);
|
||||
|
||||
if (isDefined(result?.data?.executeOneServerlessFunction?.data)) {
|
||||
callback?.(result?.data?.executeOneServerlessFunction?.data);
|
||||
}
|
||||
|
||||
setServerlessFunctionTestData((prev) => ({
|
||||
...prev,
|
||||
language: 'json',
|
||||
height: 300,
|
||||
output: {
|
||||
data: result?.data?.executeOneServerlessFunction?.data
|
||||
? JSON.stringify(
|
||||
result?.data?.executeOneServerlessFunction?.data,
|
||||
null,
|
||||
4,
|
||||
)
|
||||
: undefined,
|
||||
duration: result?.data?.executeOneServerlessFunction?.duration,
|
||||
status: result?.data?.executeOneServerlessFunction?.status,
|
||||
error: result?.data?.executeOneServerlessFunction?.error
|
||||
? JSON.stringify(
|
||||
result?.data?.executeOneServerlessFunction?.error,
|
||||
null,
|
||||
4,
|
||||
)
|
||||
: undefined,
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
setIsBuilding(false);
|
||||
setIsTesting(false);
|
||||
throw error;
|
||||
}
|
||||
|
||||
setServerlessFunctionTestData((prev) => ({
|
||||
...prev,
|
||||
language: 'json',
|
||||
height: 300,
|
||||
output: {
|
||||
data: result?.data?.executeOneServerlessFunction?.data
|
||||
? JSON.stringify(
|
||||
result?.data?.executeOneServerlessFunction?.data,
|
||||
null,
|
||||
4,
|
||||
)
|
||||
: undefined,
|
||||
duration: result?.data?.executeOneServerlessFunction?.duration,
|
||||
status: result?.data?.executeOneServerlessFunction?.status,
|
||||
error: result?.data?.executeOneServerlessFunction?.error
|
||||
? JSON.stringify(
|
||||
result?.data?.executeOneServerlessFunction?.error,
|
||||
null,
|
||||
4,
|
||||
)
|
||||
: undefined,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
return { testServerlessFunction };
|
||||
return { testServerlessFunction, isTesting, isBuilding };
|
||||
};
|
||||
|
||||
@ -0,0 +1,13 @@
|
||||
import { gql } from '@apollo/client';
|
||||
import { SERVERLESS_FUNCTION_FRAGMENT } from '@/settings/serverless-functions/graphql/fragments/serverlessFunctionFragment';
|
||||
|
||||
export const BUILD_DRAFT_SERVERLESS_FUNCTION = gql`
|
||||
${SERVERLESS_FUNCTION_FRAGMENT}
|
||||
mutation BuildDraftServerlessFunction(
|
||||
$input: BuildDraftServerlessFunctionInput!
|
||||
) {
|
||||
buildDraftServerlessFunction(input: $input) {
|
||||
...ServerlessFunctionFields
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,29 @@
|
||||
import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetadataClient';
|
||||
import { useMutation } from '@apollo/client';
|
||||
import { BUILD_DRAFT_SERVERLESS_FUNCTION } from '@/settings/serverless-functions/graphql/mutations/buildDraftServerlessFunction';
|
||||
import {
|
||||
BuildDraftServerlessFunctionMutation,
|
||||
BuildDraftServerlessFunctionMutationVariables,
|
||||
BuildDraftServerlessFunctionInput,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
export const useBuildDraftServerlessFunction = () => {
|
||||
const apolloMetadataClient = useApolloMetadataClient();
|
||||
const [mutate] = useMutation<
|
||||
BuildDraftServerlessFunctionMutation,
|
||||
BuildDraftServerlessFunctionMutationVariables
|
||||
>(BUILD_DRAFT_SERVERLESS_FUNCTION, {
|
||||
client: apolloMetadataClient,
|
||||
});
|
||||
|
||||
const buildDraftServerlessFunction = async (
|
||||
input: BuildDraftServerlessFunctionInput,
|
||||
) => {
|
||||
return await mutate({
|
||||
variables: {
|
||||
input,
|
||||
},
|
||||
});
|
||||
};
|
||||
return { buildDraftServerlessFunction };
|
||||
};
|
||||
@ -25,6 +25,7 @@ export const useGetOneServerlessFunctionSourceCode = ({
|
||||
input: { id, version },
|
||||
},
|
||||
onCompleted,
|
||||
fetchPolicy: 'network-only',
|
||||
});
|
||||
return { code: data?.getServerlessFunctionSourceCode, loading };
|
||||
};
|
||||
|
||||
@ -6,9 +6,6 @@ import {
|
||||
UpdateOneServerlessFunctionMutationVariables,
|
||||
UpdateServerlessFunctionInput,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FIND_ONE_SERVERLESS_FUNCTION } from '@/settings/serverless-functions/graphql/queries/findOneServerlessFunction';
|
||||
import { sleep } from '~/utils/sleep';
|
||||
import { getOperationName } from '@apollo/client/utilities';
|
||||
import { FIND_ONE_SERVERLESS_FUNCTION_SOURCE_CODE } from '@/settings/serverless-functions/graphql/queries/findOneServerlessFunctionSourceCode';
|
||||
|
||||
@ -16,7 +13,6 @@ export const useUpdateOneServerlessFunction = (
|
||||
serverlessFunctionId: string,
|
||||
) => {
|
||||
const apolloMetadataClient = useApolloMetadataClient();
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [mutate] = useMutation<
|
||||
UpdateOneServerlessFunctionMutation,
|
||||
UpdateOneServerlessFunctionMutationVariables
|
||||
@ -27,7 +23,7 @@ export const useUpdateOneServerlessFunction = (
|
||||
const updateOneServerlessFunction = async (
|
||||
input: Omit<UpdateServerlessFunctionInput, 'id'>,
|
||||
) => {
|
||||
const result = await mutate({
|
||||
return await mutate({
|
||||
variables: {
|
||||
input: { ...input, id: serverlessFunctionId },
|
||||
},
|
||||
@ -35,37 +31,7 @@ export const useUpdateOneServerlessFunction = (
|
||||
getOperationName(FIND_ONE_SERVERLESS_FUNCTION_SOURCE_CODE) ?? '',
|
||||
],
|
||||
});
|
||||
setIsReady(false);
|
||||
return result;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const pollFunctionStatus = async () => {
|
||||
while (isMounted && !isReady) {
|
||||
const { data } = await apolloMetadataClient.query({
|
||||
query: FIND_ONE_SERVERLESS_FUNCTION,
|
||||
variables: { input: { id: serverlessFunctionId } },
|
||||
fetchPolicy: 'network-only', // Always fetch fresh data
|
||||
});
|
||||
|
||||
const serverlessFunction = data?.findOneServerlessFunction;
|
||||
|
||||
if (serverlessFunction?.syncStatus === 'READY') {
|
||||
setIsReady(true);
|
||||
break;
|
||||
}
|
||||
await sleep(500);
|
||||
}
|
||||
};
|
||||
|
||||
pollFunctionStatus();
|
||||
|
||||
return () => {
|
||||
isMounted = false; // Cleanup when the component unmounts
|
||||
};
|
||||
}, [serverlessFunctionId, apolloMetadataClient, isReady]);
|
||||
|
||||
return { updateOneServerlessFunction, isReady };
|
||||
return { updateOneServerlessFunction };
|
||||
};
|
||||
|
||||
@ -13,7 +13,10 @@ export type ServerlessFunctionTestData = {
|
||||
height: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_OUTPUT_VALUE = 'Enter an input above then press "Test"';
|
||||
export const DEFAULT_OUTPUT_VALUE = {
|
||||
data: 'Enter an input above then press "Test"',
|
||||
status: ServerlessFunctionExecutionStatus.IDLE,
|
||||
};
|
||||
|
||||
export const serverlessFunctionTestDataFamilyState = createFamilyState<
|
||||
ServerlessFunctionTestData,
|
||||
@ -24,6 +27,6 @@ export const serverlessFunctionTestDataFamilyState = createFamilyState<
|
||||
language: 'plaintext',
|
||||
height: 64,
|
||||
input: {},
|
||||
output: { data: DEFAULT_OUTPUT_VALUE },
|
||||
output: DEFAULT_OUTPUT_VALUE,
|
||||
},
|
||||
});
|
||||
|
||||
@ -86,12 +86,14 @@ export const WorkflowEditActionFormServerlessFunction = ({
|
||||
}: WorkflowEditActionFormServerlessFunctionProps) => {
|
||||
const theme = useTheme();
|
||||
const { getIcon } = useIcons();
|
||||
const [shouldBuildServerlessFunction, setShouldBuildServerlessFunction] =
|
||||
useState(false);
|
||||
const serverlessFunctionId = action.settings.input.serverlessFunctionId;
|
||||
const serverlessFunctionVersion =
|
||||
action.settings.input.serverlessFunctionVersion;
|
||||
const tabListId = `${WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID}_${serverlessFunctionId}`;
|
||||
const { activeTabId, setActiveTabId } = useTabList(tabListId);
|
||||
const { updateOneServerlessFunction, isReady } =
|
||||
const { activeTabId } = useTabList(tabListId);
|
||||
const { updateOneServerlessFunction } =
|
||||
useUpdateOneServerlessFunction(serverlessFunctionId);
|
||||
const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion();
|
||||
|
||||
@ -126,13 +128,14 @@ export const WorkflowEditActionFormServerlessFunction = ({
|
||||
});
|
||||
};
|
||||
|
||||
const { testServerlessFunction } = useTestServerlessFunction({
|
||||
serverlessFunctionId,
|
||||
serverlessFunctionVersion,
|
||||
callback: updateOutputSchemaFromTestResult,
|
||||
});
|
||||
const { testServerlessFunction, isTesting, isBuilding } =
|
||||
useTestServerlessFunction({
|
||||
serverlessFunctionId,
|
||||
callback: updateOutputSchemaFromTestResult,
|
||||
});
|
||||
|
||||
const handleSave = useDebouncedCallback(async () => {
|
||||
setShouldBuildServerlessFunction(true);
|
||||
await updateOneServerlessFunction({
|
||||
name: formValues.name,
|
||||
description: formValues.description,
|
||||
@ -231,8 +234,10 @@ export const WorkflowEditActionFormServerlessFunction = ({
|
||||
};
|
||||
|
||||
const handleRunFunction = async () => {
|
||||
await testServerlessFunction();
|
||||
setActiveTabId('test');
|
||||
if (!isTesting) {
|
||||
await testServerlessFunction(shouldBuildServerlessFunction);
|
||||
setShouldBuildServerlessFunction(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditorDidMount = async (
|
||||
@ -313,7 +318,6 @@ export const WorkflowEditActionFormServerlessFunction = ({
|
||||
readonly={actionOptions.readonly}
|
||||
/>
|
||||
<StyledCodeEditorContainer>
|
||||
<InputLabel>Code {!isReady && <span>•</span>}</InputLabel>
|
||||
<CodeEditor
|
||||
height={343}
|
||||
value={formValues.code?.[INDEX_FILE_PATH]}
|
||||
@ -339,6 +343,8 @@ export const WorkflowEditActionFormServerlessFunction = ({
|
||||
<InputLabel>Result</InputLabel>
|
||||
<ServerlessFunctionExecutionResult
|
||||
serverlessFunctionTestData={serverlessFunctionTestData}
|
||||
isBuilding={isBuilding}
|
||||
isTesting={isTesting}
|
||||
/>
|
||||
</StyledCodeEditorContainer>
|
||||
</>
|
||||
@ -347,7 +353,11 @@ export const WorkflowEditActionFormServerlessFunction = ({
|
||||
{activeTabId === 'test' && (
|
||||
<RightDrawerFooter
|
||||
actions={[
|
||||
<CmdEnterActionButton title="Test" onClick={handleRunFunction} />,
|
||||
<CmdEnterActionButton
|
||||
title="Test"
|
||||
onClick={handleRunFunction}
|
||||
disabled={isTesting || isBuilding}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user