From ae627891598705e718a74a7aff4d44a9f9a08163 Mon Sep 17 00:00:00 2001 From: martmull Date: Fri, 31 Jan 2025 17:12:42 +0100 Subject: [PATCH] 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 --- .../src/generated-metadata/gql.ts | 5 + .../src/generated-metadata/graphql.ts | 21 ++++ .../components/CmdEnterActionButton.tsx | 3 + .../ServerlessFunctionExecutionResult.tsx | 96 ++++++++++++++----- .../hooks/useTestServerlessFunction.ts | 91 +++++++++++------- .../mutations/buildDraftServerlessFunction.ts | 13 +++ .../hooks/useBuildDraftServerlessFunction.ts | 29 ++++++ .../useGetOneServerlessFunctionSourceCode.ts | 1 + .../hooks/useUpdateOneServerlessFunction.ts | 38 +------- .../serverlessFunctionTestDataFamilyState.ts | 7 +- ...rkflowEditActionFormServerlessFunction.tsx | 32 ++++--- ...8233783889-addNewSyncStatusToServerless.ts | 49 ++++++++++ .../serverless/drivers/lambda.driver.ts | 16 ++-- .../build-draft-serverless-function.input.ts | 9 ++ ...erverless-function-execution-result.dto.ts | 1 + .../jobs/build-serverless-function.job.ts | 61 ------------ .../serverless-function.entity.ts | 1 + .../serverless-function.exception.ts | 1 + .../serverless-function.module.ts | 7 +- .../serverless-function.resolver.ts | 19 ++++ .../serverless-function.service.ts | 82 ++++++++-------- ...ion-graphql-api-exception-handler.utils.ts | 1 + .../display/icon/components/TablerIcons.ts | 2 + .../code-editor/components/CodeEditor.tsx | 33 ++++++- .../src/theme/constants/Animation.ts | 1 + .../components/AnimatedCircleLoading.tsx | 33 +++++++ .../src/utilities/animation/index.ts | 1 + packages/twenty-ui/src/utilities/index.ts | 1 + 28 files changed, 430 insertions(+), 224 deletions(-) create mode 100644 packages/twenty-front/src/modules/settings/serverless-functions/graphql/mutations/buildDraftServerlessFunction.ts create mode 100644 packages/twenty-front/src/modules/settings/serverless-functions/hooks/useBuildDraftServerlessFunction.ts create mode 100644 packages/twenty-server/src/database/typeorm/metadata/migrations/1738233783889-addNewSyncStatusToServerless.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/build-draft-serverless-function.input.ts delete mode 100644 packages/twenty-server/src/engine/metadata-modules/serverless-function/jobs/build-serverless-function.job.ts create mode 100644 packages/twenty-ui/src/utilities/animation/components/AnimatedCircleLoading.tsx diff --git a/packages/twenty-front/src/generated-metadata/gql.ts b/packages/twenty-front/src/generated-metadata/gql.ts index 2a376b33b..39b21a1b2 100644 --- a/packages/twenty-front/src/generated-metadata/gql.ts +++ b/packages/twenty-front/src/generated-metadata/gql.ts @@ -34,6 +34,7 @@ const documents = { "\n mutation DeleteOneRelationMetadataItem($idToDelete: UUID!) {\n deleteOneRelation(input: { id: $idToDelete }) {\n id\n }\n }\n": types.DeleteOneRelationMetadataItemDocument, "\n query ObjectMetadataItems(\n $objectFilter: ObjectFilter\n $fieldFilter: FieldFilter\n ) {\n objects(paging: { first: 1000 }, filter: $objectFilter) {\n edges {\n node {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isRemote\n isActive\n isSystem\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n shortcut\n isLabelSyncedWithName\n indexMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n name\n indexWhereClause\n indexType\n isUnique\n indexFieldMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n order\n fieldMetadataId\n }\n }\n }\n }\n }\n }\n fields(paging: { first: 1000 }, filter: $fieldFilter) {\n edges {\n node {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isSystem\n isNullable\n isUnique\n createdAt\n updatedAt\n defaultValue\n options\n settings\n isLabelSyncedWithName\n relationDefinition {\n relationId\n direction\n sourceObjectMetadata {\n id\n nameSingular\n namePlural\n }\n sourceFieldMetadata {\n id\n name\n }\n targetObjectMetadata {\n id\n nameSingular\n namePlural\n }\n targetFieldMetadata {\n id\n name\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n": types.ObjectMetadataItemsDocument, "\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n runtime\n timeoutSeconds\n syncStatus\n latestVersion\n latestVersionInputSchema\n publishedVersions\n createdAt\n updatedAt\n }\n": types.ServerlessFunctionFieldsFragmentDoc, + "\n \n mutation BuildDraftServerlessFunction(\n $input: BuildDraftServerlessFunctionInput!\n ) {\n buildDraftServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.BuildDraftServerlessFunctionDocument, "\n \n mutation CreateOneServerlessFunctionItem(\n $input: CreateServerlessFunctionInput!\n ) {\n createOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.CreateOneServerlessFunctionItemDocument, "\n \n mutation DeleteOneServerlessFunction($input: ServerlessFunctionIdInput!) {\n deleteOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.DeleteOneServerlessFunctionDocument, "\n mutation ExecuteOneServerlessFunction(\n $input: ExecuteServerlessFunctionInput!\n ) {\n executeOneServerlessFunction(input: $input) {\n data\n duration\n status\n error\n }\n }\n": types.ExecuteOneServerlessFunctionDocument, @@ -143,6 +144,10 @@ export function graphql(source: "\n query ObjectMetadataItems(\n $objectFilt * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n runtime\n timeoutSeconds\n syncStatus\n latestVersion\n latestVersionInputSchema\n publishedVersions\n createdAt\n updatedAt\n }\n"): (typeof documents)["\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n runtime\n timeoutSeconds\n syncStatus\n latestVersion\n latestVersionInputSchema\n publishedVersions\n createdAt\n updatedAt\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n \n mutation BuildDraftServerlessFunction(\n $input: BuildDraftServerlessFunctionInput!\n ) {\n buildDraftServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n"): (typeof documents)["\n \n mutation BuildDraftServerlessFunction(\n $input: BuildDraftServerlessFunctionInput!\n ) {\n buildDraftServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index e4ac71868..820e9abfc 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -224,6 +224,11 @@ export type BooleanFieldComparison = { isNot?: InputMaybe; }; +export type BuildDraftServerlessFunctionInput = { + /** The id of the function. */ + id: Scalars['ID']['input']; +}; + export enum CalendarChannelVisibility { METADATA = 'METADATA', SHARE_EVERYTHING = 'SHARE_EVERYTHING' @@ -763,6 +768,7 @@ export type Mutation = { activateWorkflowVersion: Scalars['Boolean']['output']; activateWorkspace: Workspace; authorizeApp: AuthorizeApp; + buildDraftServerlessFunction: ServerlessFunction; checkoutSession: BillingSessionOutput; computeStepOutputSchema: Scalars['JSON']['output']; createDraftFromWorkflowVersion: WorkflowVersion; @@ -845,6 +851,11 @@ export type MutationAuthorizeAppArgs = { }; +export type MutationBuildDraftServerlessFunctionArgs = { + input: BuildDraftServerlessFunctionInput; +}; + + export type MutationCheckoutSessionArgs = { plan?: BillingPlanKey; recurringInterval: SubscriptionInterval; @@ -1647,6 +1658,7 @@ export type ServerlessFunctionExecutionResult = { /** Status of the serverless function execution */ export enum ServerlessFunctionExecutionStatus { ERROR = 'ERROR', + IDLE = 'IDLE', SUCCESS = 'SUCCESS' } @@ -1657,6 +1669,7 @@ export type ServerlessFunctionIdInput = { /** SyncStatus of the serverlessFunction */ export enum ServerlessFunctionSyncStatus { + BUILDING = 'BUILDING', NOT_READY = 'NOT_READY', READY = 'READY' } @@ -2239,6 +2252,13 @@ export type ObjectMetadataItemsQuery = { __typename?: 'Query', objects: { __type export type ServerlessFunctionFieldsFragment = { __typename?: 'ServerlessFunction', id: any, name: string, description?: string | null, runtime: string, timeoutSeconds: number, syncStatus: ServerlessFunctionSyncStatus, latestVersion?: string | null, latestVersionInputSchema?: any | null, publishedVersions: Array, createdAt: any, updatedAt: any }; +export type BuildDraftServerlessFunctionMutationVariables = Exact<{ + input: BuildDraftServerlessFunctionInput; +}>; + + +export type BuildDraftServerlessFunctionMutation = { __typename?: 'Mutation', buildDraftServerlessFunction: { __typename?: 'ServerlessFunction', id: any, name: string, description?: string | null, runtime: string, timeoutSeconds: number, syncStatus: ServerlessFunctionSyncStatus, latestVersion?: string | null, latestVersionInputSchema?: any | null, publishedVersions: Array, createdAt: any, updatedAt: any } }; + export type CreateOneServerlessFunctionItemMutationVariables = Exact<{ input: CreateServerlessFunctionInput; }>; @@ -2321,6 +2341,7 @@ export const DeleteOneObjectMetadataItemDocument = {"kind":"Document","definitio export const DeleteOneFieldMetadataItemDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteOneFieldMetadataItem"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"idToDelete"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteOneField"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"idToDelete"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"isCustom"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"isNullable"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"settings"}}]}}]}}]} as unknown as DocumentNode; export const DeleteOneRelationMetadataItemDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteOneRelationMetadataItem"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"idToDelete"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteOneRelation"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"idToDelete"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; export const ObjectMetadataItemsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ObjectMetadataItems"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"objectFilter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ObjectFilter"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"fieldFilter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"FieldFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"objects"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"paging"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"1000"}}]}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"objectFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"dataSourceId"}},{"kind":"Field","name":{"kind":"Name","value":"nameSingular"}},{"kind":"Field","name":{"kind":"Name","value":"namePlural"}},{"kind":"Field","name":{"kind":"Name","value":"labelSingular"}},{"kind":"Field","name":{"kind":"Name","value":"labelPlural"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"isCustom"}},{"kind":"Field","name":{"kind":"Name","value":"isRemote"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"isSystem"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"labelIdentifierFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"imageIdentifierFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"shortcut"}},{"kind":"Field","name":{"kind":"Name","value":"isLabelSyncedWithName"}},{"kind":"Field","name":{"kind":"Name","value":"indexMetadatas"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"paging"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"100"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"indexWhereClause"}},{"kind":"Field","name":{"kind":"Name","value":"indexType"}},{"kind":"Field","name":{"kind":"Name","value":"isUnique"}},{"kind":"Field","name":{"kind":"Name","value":"indexFieldMetadatas"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"paging"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"100"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"fieldMetadataId"}}]}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"fields"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"paging"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"1000"}}]}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"fieldFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"isCustom"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"isSystem"}},{"kind":"Field","name":{"kind":"Name","value":"isNullable"}},{"kind":"Field","name":{"kind":"Name","value":"isUnique"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"defaultValue"}},{"kind":"Field","name":{"kind":"Name","value":"options"}},{"kind":"Field","name":{"kind":"Name","value":"settings"}},{"kind":"Field","name":{"kind":"Name","value":"isLabelSyncedWithName"}},{"kind":"Field","name":{"kind":"Name","value":"relationDefinition"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"relationId"}},{"kind":"Field","name":{"kind":"Name","value":"direction"}},{"kind":"Field","name":{"kind":"Name","value":"sourceObjectMetadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"nameSingular"}},{"kind":"Field","name":{"kind":"Name","value":"namePlural"}}]}},{"kind":"Field","name":{"kind":"Name","value":"sourceFieldMetadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"targetObjectMetadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"nameSingular"}},{"kind":"Field","name":{"kind":"Name","value":"namePlural"}}]}},{"kind":"Field","name":{"kind":"Name","value":"targetFieldMetadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"hasPreviousPage"}},{"kind":"Field","name":{"kind":"Name","value":"startCursor"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"hasPreviousPage"}},{"kind":"Field","name":{"kind":"Name","value":"startCursor"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]} as unknown as DocumentNode; +export const BuildDraftServerlessFunctionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"BuildDraftServerlessFunction"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BuildDraftServerlessFunctionInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"buildDraftServerlessFunction"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ServerlessFunctionFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ServerlessFunctionFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ServerlessFunction"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"runtime"}},{"kind":"Field","name":{"kind":"Name","value":"timeoutSeconds"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersion"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersionInputSchema"}},{"kind":"Field","name":{"kind":"Name","value":"publishedVersions"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; export const CreateOneServerlessFunctionItemDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateOneServerlessFunctionItem"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateServerlessFunctionInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createOneServerlessFunction"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ServerlessFunctionFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ServerlessFunctionFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ServerlessFunction"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"runtime"}},{"kind":"Field","name":{"kind":"Name","value":"timeoutSeconds"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersion"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersionInputSchema"}},{"kind":"Field","name":{"kind":"Name","value":"publishedVersions"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; export const DeleteOneServerlessFunctionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteOneServerlessFunction"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ServerlessFunctionIdInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteOneServerlessFunction"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ServerlessFunctionFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ServerlessFunctionFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ServerlessFunction"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"runtime"}},{"kind":"Field","name":{"kind":"Name","value":"timeoutSeconds"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersion"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersionInputSchema"}},{"kind":"Field","name":{"kind":"Name","value":"publishedVersions"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; export const ExecuteOneServerlessFunctionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ExecuteOneServerlessFunction"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ExecuteServerlessFunctionInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"executeOneServerlessFunction"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"data"}},{"kind":"Field","name":{"kind":"Name","value":"duration"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}}]}}]} as unknown as DocumentNode; diff --git a/packages/twenty-front/src/modules/action-menu/components/CmdEnterActionButton.tsx b/packages/twenty-front/src/modules/action-menu/components/CmdEnterActionButton.tsx index 48fa4680e..bbc492cb8 100644 --- a/packages/twenty-front/src/modules/action-menu/components/CmdEnterActionButton.tsx +++ b/packages/twenty-front/src/modules/action-menu/components/CmdEnterActionButton.tsx @@ -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(), '⏎']} /> ); diff --git a/packages/twenty-front/src/modules/serverless-functions/components/ServerlessFunctionExecutionResult.tsx b/packages/twenty-front/src/modules/serverless-functions/components/ServerlessFunctionExecutionResult.tsx index 40ab34052..17d79914f 100644 --- a/packages/twenty-front/src/modules/serverless-functions/components/ServerlessFunctionExecutionResult.tsx +++ b/packages/twenty-front/src/modules/serverless-functions/components/ServerlessFunctionExecutionResult.tsx @@ -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' - ) : ( - - - {serverlessFunctionTestData.output.status === - ServerlessFunctionExecutionStatus.SUCCESS - ? '200 OK' - : '500 Error'} - {' - '} - {serverlessFunctionTestData.output.duration}ms - - ); + const SuccessLeftNode = ( + + + 200 OK - {serverlessFunctionTestData.output.duration}ms + + ); + + const ErrorLeftNode = ( + + + 500 Error - {serverlessFunctionTestData.output.duration}ms + + ); + + const IdleLeftNode = 'Output'; + + const PendingLeftNode = (isTesting || isBuilding) && ( + + + {isTesting ? ( + + ) : ( + + )} + + + {isTesting ? 'Running function' : 'Building function'} + + + ); + + 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 ( ]} /> diff --git a/packages/twenty-front/src/modules/serverless-functions/hooks/useTestServerlessFunction.ts b/packages/twenty-front/src/modules/serverless-functions/hooks/useTestServerlessFunction.ts index 5c40270c2..8ca50d4a4 100644 --- a/packages/twenty-front/src/modules/serverless-functions/hooks/useTestServerlessFunction.ts +++ b/packages/twenty-front/src/modules/serverless-functions/hooks/useTestServerlessFunction.ts @@ -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 }; }; diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/graphql/mutations/buildDraftServerlessFunction.ts b/packages/twenty-front/src/modules/settings/serverless-functions/graphql/mutations/buildDraftServerlessFunction.ts new file mode 100644 index 000000000..0767d9fee --- /dev/null +++ b/packages/twenty-front/src/modules/settings/serverless-functions/graphql/mutations/buildDraftServerlessFunction.ts @@ -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 + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useBuildDraftServerlessFunction.ts b/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useBuildDraftServerlessFunction.ts new file mode 100644 index 000000000..6c18b6760 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useBuildDraftServerlessFunction.ts @@ -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 }; +}; diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useGetOneServerlessFunctionSourceCode.ts b/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useGetOneServerlessFunctionSourceCode.ts index b39f91903..82d2b78b0 100644 --- a/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useGetOneServerlessFunctionSourceCode.ts +++ b/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useGetOneServerlessFunctionSourceCode.ts @@ -25,6 +25,7 @@ export const useGetOneServerlessFunctionSourceCode = ({ input: { id, version }, }, onCompleted, + fetchPolicy: 'network-only', }); return { code: data?.getServerlessFunctionSourceCode, loading }; }; diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useUpdateOneServerlessFunction.ts b/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useUpdateOneServerlessFunction.ts index 95218a9dc..6680c0474 100644 --- a/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useUpdateOneServerlessFunction.ts +++ b/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useUpdateOneServerlessFunction.ts @@ -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, ) => { - 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 }; }; diff --git a/packages/twenty-front/src/modules/workflow/states/serverlessFunctionTestDataFamilyState.ts b/packages/twenty-front/src/modules/workflow/states/serverlessFunctionTestDataFamilyState.ts index 4f297fa74..65050a520 100644 --- a/packages/twenty-front/src/modules/workflow/states/serverlessFunctionTestDataFamilyState.ts +++ b/packages/twenty-front/src/modules/workflow/states/serverlessFunctionTestDataFamilyState.ts @@ -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, }, }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFormServerlessFunction.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFormServerlessFunction.tsx index 6ad61b5c5..642e3e86e 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFormServerlessFunction.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFormServerlessFunction.tsx @@ -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} /> - Code {!isReady && } Result @@ -347,7 +353,11 @@ export const WorkflowEditActionFormServerlessFunction = ({ {activeTabId === 'test' && ( , + , ]} /> )} diff --git a/packages/twenty-server/src/database/typeorm/metadata/migrations/1738233783889-addNewSyncStatusToServerless.ts b/packages/twenty-server/src/database/typeorm/metadata/migrations/1738233783889-addNewSyncStatusToServerless.ts new file mode 100644 index 000000000..9e73cdf36 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/metadata/migrations/1738233783889-addNewSyncStatusToServerless.ts @@ -0,0 +1,49 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddNewSyncStatusToServerless1738233783889 + implements MigrationInterface +{ + name = 'AddNewSyncStatusToServerless1738233783889'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TYPE "metadata"."serverlessFunction_syncstatus_enum" RENAME TO "serverlessFunction_syncstatus_enum_old"`, + ); + await queryRunner.query( + `CREATE TYPE "metadata"."serverlessFunction_syncstatus_enum" AS ENUM('NOT_READY', 'BUILDING', 'READY')`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."serverlessFunction" ALTER COLUMN "syncStatus" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."serverlessFunction" ALTER COLUMN "syncStatus" TYPE "metadata"."serverlessFunction_syncstatus_enum" USING "syncStatus"::"text"::"metadata"."serverlessFunction_syncstatus_enum"`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."serverlessFunction" ALTER COLUMN "syncStatus" SET DEFAULT 'NOT_READY'`, + ); + await queryRunner.query( + `DROP TYPE "metadata"."serverlessFunction_syncstatus_enum_old"`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "metadata"."serverlessFunction_syncstatus_enum_old" AS ENUM('NOT_READY', 'READY')`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."serverlessFunction" ALTER COLUMN "syncStatus" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."serverlessFunction" ALTER COLUMN "syncStatus" TYPE "metadata"."serverlessFunction_syncstatus_enum_old" USING "syncStatus"::"text"::"metadata"."serverlessFunction_syncstatus_enum_old"`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."serverlessFunction" ALTER COLUMN "syncStatus" SET DEFAULT 'NOT_READY'`, + ); + await queryRunner.query( + `DROP TYPE "metadata"."serverlessFunction_syncstatus_enum"`, + ); + await queryRunner.query( + `ALTER TYPE "metadata"."serverlessFunction_syncstatus_enum_old" RENAME TO "serverlessFunction_syncstatus_enum"`, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/serverless/drivers/lambda.driver.ts b/packages/twenty-server/src/engine/core-modules/serverless/drivers/lambda.driver.ts index 08e9b719a..2b96e9348 100644 --- a/packages/twenty-server/src/engine/core-modules/serverless/drivers/lambda.driver.ts +++ b/packages/twenty-server/src/engine/core-modules/serverless/drivers/lambda.driver.ts @@ -54,7 +54,7 @@ import { compileTypescript } from 'src/engine/core-modules/serverless/drivers/ut import { ENV_FILE_NAME } from 'src/engine/core-modules/serverless/drivers/constants/env-file-name'; import { OUTDIR_FOLDER } from 'src/engine/core-modules/serverless/drivers/constants/outdir-folder'; -const UPDATE_FUNCTION_DURATION_TIMEOUT_IN_SECONDS = 30; +const UPDATE_FUNCTION_DURATION_TIMEOUT_IN_SECONDS = 60; export interface LambdaDriverOptions extends LambdaClientConfig { fileStorageService: FileStorageService; @@ -133,7 +133,7 @@ export class LambdaDriver implements ServerlessDriver { await lambdaBuildDirectoryManager.clean(); if (!isDefined(result.LayerVersionArn)) { - throw new Error('new layer version arn si undefined'); + throw new Error('new layer version arn if undefined'); } return result.LayerVersionArn; @@ -177,15 +177,13 @@ export class LambdaDriver implements ServerlessDriver { return join(SERVERLESS_TMPDIR_FOLDER, serverlessFunction.id, version); }; - async build(serverlessFunction: ServerlessFunctionEntity, version: string) { - const computedVersion = - version === 'latest' ? serverlessFunction.latestVersion : version; + async build(serverlessFunction: ServerlessFunctionEntity, version: 'draft') { + if (version !== 'draft') { + throw new Error("We can only build 'draft' version with lambda driver"); + } const inMemoryServerlessFunctionFolderPath = - this.getInMemoryServerlessFunctionFolderPath( - serverlessFunction, - computedVersion, - ); + this.getInMemoryServerlessFunctionFolderPath(serverlessFunction, version); const folderPath = getServerlessFolder({ serverlessFunction, diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/build-draft-serverless-function.input.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/build-draft-serverless-function.input.ts new file mode 100644 index 000000000..3b47ec6e2 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/build-draft-serverless-function.input.ts @@ -0,0 +1,9 @@ +import { ID, InputType } from '@nestjs/graphql'; + +import { IDField } from '@ptc-org/nestjs-query-graphql'; + +@InputType() +export class BuildDraftServerlessFunctionInput { + @IDField(() => ID, { description: 'The id of the function.' }) + id!: string; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/serverless-function-execution-result.dto.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/serverless-function-execution-result.dto.ts index cbd87a3dc..a839a6af6 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/serverless-function-execution-result.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/serverless-function-execution-result.dto.ts @@ -4,6 +4,7 @@ import { IsObject, IsOptional } from 'class-validator'; import graphqlTypeJson from 'graphql-type-json'; export enum ServerlessFunctionExecutionStatus { + IDLE = 'IDLE', SUCCESS = 'SUCCESS', ERROR = 'ERROR', } diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/jobs/build-serverless-function.job.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/jobs/build-serverless-function.job.ts deleted file mode 100644 index 317219d8f..000000000 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/jobs/build-serverless-function.job.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Scope } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; - -import { Repository } from 'typeorm'; - -import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator'; -import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; -import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator'; -import { ServerlessService } from 'src/engine/core-modules/serverless/serverless.service'; -import { - ServerlessFunctionEntity, - ServerlessFunctionSyncStatus, -} from 'src/engine/metadata-modules/serverless-function/serverless-function.entity'; -import { isDefined } from 'src/utils/is-defined'; - -export type BuildServerlessFunctionBatchEvent = { - serverlessFunctions: { - serverlessFunctionId: string; - serverlessFunctionVersion: string; - }[]; - workspaceId: string; -}; - -@Processor({ - queueName: MessageQueue.serverlessFunctionQueue, - scope: Scope.REQUEST, -}) -export class BuildServerlessFunctionJob { - constructor( - @InjectRepository(ServerlessFunctionEntity, 'metadata') - private readonly serverlessFunctionRepository: Repository, - private readonly serverlessService: ServerlessService, - ) {} - - @Process(BuildServerlessFunctionJob.name) - async handle(batchEvent: BuildServerlessFunctionBatchEvent): Promise { - for (const { - serverlessFunctionId, - serverlessFunctionVersion, - } of batchEvent.serverlessFunctions) { - const serverlessFunction = - await this.serverlessFunctionRepository.findOneBy({ - id: serverlessFunctionId, - workspaceId: batchEvent.workspaceId, - }); - - if (isDefined(serverlessFunction)) { - await this.serverlessFunctionRepository.update(serverlessFunction.id, { - syncStatus: ServerlessFunctionSyncStatus.NOT_READY, - }); - await this.serverlessService.build( - serverlessFunction, - serverlessFunctionVersion, - ); - await this.serverlessFunctionRepository.update(serverlessFunction.id, { - syncStatus: ServerlessFunctionSyncStatus.READY, - }); - } - } - } -} diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.entity.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.entity.ts index 7aa9ee725..3b74a39cd 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.entity.ts @@ -13,6 +13,7 @@ const DEFAULT_SERVERLESS_TIMEOUT_SECONDS = 300; // 5 minutes export enum ServerlessFunctionSyncStatus { NOT_READY = 'NOT_READY', + BUILDING = 'BUILDING', READY = 'READY', } diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.exception.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.exception.ts index 1201e47a9..f5280c89d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.exception.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.exception.ts @@ -13,5 +13,6 @@ export enum ServerlessFunctionExceptionCode { FEATURE_FLAG_INVALID = 'FEATURE_FLAG_INVALID', SERVERLESS_FUNCTION_ALREADY_EXIST = 'SERVERLESS_FUNCTION_ALREADY_EXIST', SERVERLESS_FUNCTION_NOT_READY = 'SERVERLESS_FUNCTION_NOT_READY', + SERVERLESS_FUNCTION_BUILDING = 'SERVERLESS_FUNCTION_BUILDING', SERVERLESS_FUNCTION_EXECUTION_LIMIT_REACHED = 'SERVERLESS_FUNCTION_EXECUTION_LIMIT_REACHED', } diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.module.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.module.ts index 981394dff..02f2b0acc 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.module.ts @@ -11,7 +11,6 @@ import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.mod import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity'; import { ServerlessFunctionResolver } from 'src/engine/metadata-modules/serverless-function/serverless-function.resolver'; import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service'; -import { BuildServerlessFunctionJob } from 'src/engine/metadata-modules/serverless-function/jobs/build-serverless-function.job'; @Module({ imports: [ @@ -22,11 +21,7 @@ import { BuildServerlessFunctionJob } from 'src/engine/metadata-modules/serverle ThrottlerModule, AnalyticsModule, ], - providers: [ - ServerlessFunctionService, - ServerlessFunctionResolver, - BuildServerlessFunctionJob, - ], + providers: [ServerlessFunctionService, ServerlessFunctionResolver], exports: [ServerlessFunctionService], }) export class ServerlessFunctionModule {} diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.resolver.ts index 716e798e5..b3c267358 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.resolver.ts @@ -24,6 +24,7 @@ import { } from 'src/engine/metadata-modules/serverless-function/serverless-function.exception'; import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service'; import { serverlessFunctionGraphQLApiExceptionHandler } from 'src/engine/metadata-modules/serverless-function/utils/serverless-function-graphql-api-exception-handler.utils'; +import { BuildDraftServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/build-draft-serverless-function.input'; @UseGuards(WorkspaceAuthGuard) @Resolver() @@ -204,4 +205,22 @@ export class ServerlessFunctionResolver { serverlessFunctionGraphQLApiExceptionHandler(error); } } + + @Mutation(() => ServerlessFunctionDTO) + async buildDraftServerlessFunction( + @Args('input') input: BuildDraftServerlessFunctionInput, + @AuthWorkspace() { id: workspaceId }: Workspace, + ) { + try { + await this.checkFeatureFlag(workspaceId); + const { id } = input; + + return await this.serverlessFunctionService.buildDraftServerlessFunction( + id, + workspaceId, + ); + } catch (error) { + serverlessFunctionGraphQLApiExceptionHandler(error); + } + } } diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.service.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.service.ts index 66cc4d0f1..cd1cde7a3 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.service.ts @@ -26,10 +26,6 @@ import { getServerlessFolder } from 'src/engine/core-modules/serverless/utils/se import { ThrottlerService } from 'src/engine/core-modules/throttler/throttler.service'; import { CreateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/create-serverless-function.input'; import { UpdateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/update-serverless-function.input'; -import { - BuildServerlessFunctionBatchEvent, - BuildServerlessFunctionJob, -} from 'src/engine/metadata-modules/serverless-function/jobs/build-serverless-function.job'; import { ServerlessFunctionEntity, ServerlessFunctionSyncStatus, @@ -141,6 +137,16 @@ export class ServerlessFunctionService { workspaceId, }); + if ( + version === 'draft' && + functionToExecute.syncStatus !== ServerlessFunctionSyncStatus.READY + ) { + await this.buildDraftServerlessFunction( + functionToExecute.id, + workspaceId, + ); + } + const resultServerlessFunction = await this.serverlessService.execute( functionToExecute, payload, @@ -276,12 +282,6 @@ export class ServerlessFunctionService { }); } - await this.buildServerlessFunction({ - serverlessFunctionId: existingServerlessFunction.id, - serverlessFunctionVersion: 'draft', - workspaceId, - }); - return this.serverlessFunctionRepository.findOneBy({ id: existingServerlessFunction.id, }); @@ -322,6 +322,7 @@ export class ServerlessFunctionService { ...serverlessFunctionInput, workspaceId, layerVersion: LAST_LAYER_VERSION, + syncStatus: ServerlessFunctionSyncStatus.NOT_READY, }); const createdServerlessFunction = @@ -341,12 +342,6 @@ export class ServerlessFunctionService { }); } - await this.buildServerlessFunction({ - serverlessFunctionId: createdServerlessFunction.id, - serverlessFunctionVersion: 'draft', - workspaceId, - }); - return this.serverlessFunctionRepository.findOneBy({ id: createdServerlessFunction.id, }); @@ -361,6 +356,10 @@ export class ServerlessFunctionService { version: string; workspaceId: string; }) { + if (version === 'draft') { + return; + } + const serverlessFunction = await this.findOneOrFail({ id, workspaceId, @@ -381,10 +380,8 @@ export class ServerlessFunctionService { }, }); - await this.buildServerlessFunction({ - serverlessFunctionId: id, - serverlessFunctionVersion: 'draft', - workspaceId, + await this.serverlessFunctionRepository.update(serverlessFunction.id, { + syncStatus: ServerlessFunctionSyncStatus.NOT_READY, }); } @@ -403,24 +400,31 @@ export class ServerlessFunctionService { } } - private async buildServerlessFunction({ - serverlessFunctionId, - serverlessFunctionVersion, - workspaceId, - }: { - serverlessFunctionId: string; - serverlessFunctionVersion: string; - workspaceId: string; - }) { - await this.messageQueueService.add( - BuildServerlessFunctionJob.name, - { - serverlessFunctions: [ - { serverlessFunctionId, serverlessFunctionVersion }, - ], - workspaceId, - }, - { id: `${serverlessFunctionId}-${serverlessFunctionVersion}` }, - ); + async buildDraftServerlessFunction(id: string, workspaceId: string) { + const functionToBuild = await this.findOneOrFail({ + id, + workspaceId, + }); + + if (functionToBuild.syncStatus === ServerlessFunctionSyncStatus.READY) { + return functionToBuild; + } + + if (functionToBuild.syncStatus === ServerlessFunctionSyncStatus.BUILDING) { + throw new ServerlessFunctionException( + 'This function is currently building. Please try later', + ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_BUILDING, + ); + } + + await this.serverlessFunctionRepository.update(functionToBuild.id, { + syncStatus: ServerlessFunctionSyncStatus.BUILDING, + }); + await this.serverlessService.build(functionToBuild, 'draft'); + await this.serverlessFunctionRepository.update(functionToBuild.id, { + syncStatus: ServerlessFunctionSyncStatus.READY, + }); + + return functionToBuild; } } diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/utils/serverless-function-graphql-api-exception-handler.utils.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/utils/serverless-function-graphql-api-exception-handler.utils.ts index 04a1ab8fd..5518e7dac 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/utils/serverless-function-graphql-api-exception-handler.utils.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/utils/serverless-function-graphql-api-exception-handler.utils.ts @@ -18,6 +18,7 @@ export const serverlessFunctionGraphQLApiExceptionHandler = (error: any) => { case ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_ALREADY_EXIST: throw new ConflictError(error.message); case ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_NOT_READY: + case ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_BUILDING: case ServerlessFunctionExceptionCode.FEATURE_FLAG_INVALID: throw new ForbiddenError(error.message); default: diff --git a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts index 13561f90f..9ccd02fae 100644 --- a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts +++ b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts @@ -173,6 +173,7 @@ export { IconListCheck, IconListDetails, IconListNumbers, + IconLoader, IconLock, IconLockOpen, IconMail, @@ -236,6 +237,7 @@ export { IconSquare, IconSquareKey, IconSquareRoundedCheck, + IconSquareRoundedX, IconTable, IconTag, IconTags, diff --git a/packages/twenty-ui/src/input/code-editor/components/CodeEditor.tsx b/packages/twenty-ui/src/input/code-editor/components/CodeEditor.tsx index a671a4434..a1102309b 100644 --- a/packages/twenty-ui/src/input/code-editor/components/CodeEditor.tsx +++ b/packages/twenty-ui/src/input/code-editor/components/CodeEditor.tsx @@ -1,3 +1,4 @@ +import { Loader } from '@ui/feedback/loader/components/Loader'; import { useTheme, css } from '@emotion/react'; import Editor, { EditorProps, Monaco } from '@monaco-editor/react'; import { codeEditorTheme } from '@ui/input'; @@ -10,8 +11,30 @@ type CodeEditorProps = Omit & { onChange?: (value: string) => void; setMarkers?: (value: string) => editor.IMarkerData[]; withHeader?: boolean; + isLoading?: boolean; }; +const StyledEditorLoader = styled.div<{ + height: string | number; + withHeader?: boolean; +}>` + align-items: center; + display: flex; + height: ${({ height }) => height}px; + justify-content: center; + border: 1px solid ${({ theme }) => theme.border.color.medium}; + background-color: ${({ theme }) => theme.background.transparent.lighter}; + ${({ withHeader, theme }) => + withHeader + ? css` + border-radius: 0 0 ${theme.border.radius.sm} ${theme.border.radius.sm}; + border-top: none; + ` + : css` + border-radius: ${theme.border.radius.sm}; + `} +`; + const StyledEditor = styled(Editor)<{ withHeader: boolean }>` .monaco-editor { border-radius: ${({ theme }) => theme.border.radius.sm}; @@ -42,6 +65,7 @@ export const CodeEditor = ({ onValidate, height = 450, withHeader = false, + isLoading = false, options, }: CodeEditorProps) => { const theme = useTheme(); @@ -64,12 +88,17 @@ export const CodeEditor = ({ } }; - return ( + return isLoading ? ( + + + + ) : ( { setMonaco(monaco); setEditor(editor); diff --git a/packages/twenty-ui/src/theme/constants/Animation.ts b/packages/twenty-ui/src/theme/constants/Animation.ts index 865d43b2b..248a76585 100644 --- a/packages/twenty-ui/src/theme/constants/Animation.ts +++ b/packages/twenty-ui/src/theme/constants/Animation.ts @@ -3,6 +3,7 @@ export const ANIMATION = { instant: 0.075, fast: 0.15, normal: 0.3, + slow: 1.5, }, }; diff --git a/packages/twenty-ui/src/utilities/animation/components/AnimatedCircleLoading.tsx b/packages/twenty-ui/src/utilities/animation/components/AnimatedCircleLoading.tsx new file mode 100644 index 000000000..cca1aee22 --- /dev/null +++ b/packages/twenty-ui/src/utilities/animation/components/AnimatedCircleLoading.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { motion } from 'framer-motion'; +import styled from '@emotion/styled'; +import { useTheme } from '@emotion/react'; + +const StyledAnimatedContainer = styled(motion.div)` + align-items: center; + display: flex; + justify-content: center; +`; + +export const AnimatedCircleLoading = ({ + children, +}: { + children: React.ReactNode; +}) => { + const theme = useTheme(); + return ( + + {children} + + ); +}; diff --git a/packages/twenty-ui/src/utilities/animation/index.ts b/packages/twenty-ui/src/utilities/animation/index.ts index fdde95366..cba279289 100644 --- a/packages/twenty-ui/src/utilities/animation/index.ts +++ b/packages/twenty-ui/src/utilities/animation/index.ts @@ -4,3 +4,4 @@ export * from './components/AnimatedEaseInOut'; export * from './components/AnimatedFadeOut'; export * from './components/AnimatedTextWord'; export * from './components/AnimatedTranslation'; +export * from './components/AnimatedCircleLoading'; diff --git a/packages/twenty-ui/src/utilities/index.ts b/packages/twenty-ui/src/utilities/index.ts index f45ce2d7a..7dcaf5d6a 100644 --- a/packages/twenty-ui/src/utilities/index.ts +++ b/packages/twenty-ui/src/utilities/index.ts @@ -1,3 +1,4 @@ +export * from './animation/components/AnimatedCircleLoading'; export * from './animation/components/AnimatedContainer'; export * from './animation/components/AnimatedEaseIn'; export * from './animation/components/AnimatedEaseInOut';