8723 workflow add editor in serverless function code step (#8805)

- create a serverless function when creating a new workflow code step
- add code editor in workflow code step
- move workflowVersion steps management from frontend to backend
  - add a custom resolver for workflow-version management
  - fix optimistic rendering on frontend
- fix css
- delete serverless function when deleting workflow code step

TODO
- Don't update serverlessFunction if no code change
- Factorize what can be between crud trigger and crud step
- Publish serverless version when activating workflow
- delete serverless functions when deleting workflow or workflowVersion
- fix optimistic rendering for code updates
- Unify CRUD types

<img width="1279" alt="image"
src="https://github.com/user-attachments/assets/3d97ee9f-4b96-4abc-9d36-5c0280058be4">
This commit is contained in:
martmull
2024-12-03 09:41:13 +01:00
committed by GitHub
parent 9d7632cb4f
commit d0ff1ffd5f
75 changed files with 2192 additions and 1527 deletions

View File

@ -1,142 +0,0 @@
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import { getStepDefaultDefinition } from '../getStepDefaultDefinition';
it('returns a valid definition for CODE actions', () => {
expect(
getStepDefaultDefinition({
type: 'CODE',
activeObjectMetadataItems: generatedMockObjectMetadataItems,
}),
).toStrictEqual({
id: expect.any(String),
name: 'Code',
type: 'CODE',
valid: false,
settings: {
input: {
serverlessFunctionId: '',
serverlessFunctionVersion: '',
serverlessFunctionInput: {},
},
outputSchema: {},
errorHandlingOptions: {
continueOnFailure: {
value: false,
},
retryOnFailure: {
value: false,
},
},
},
});
});
it('returns a valid definition for SEND_EMAIL actions', () => {
expect(
getStepDefaultDefinition({
type: 'SEND_EMAIL',
activeObjectMetadataItems: generatedMockObjectMetadataItems,
}),
).toStrictEqual({
id: expect.any(String),
name: 'Send Email',
type: 'SEND_EMAIL',
valid: false,
settings: {
input: {
connectedAccountId: '',
email: '',
subject: '',
body: '',
},
outputSchema: {},
errorHandlingOptions: {
continueOnFailure: {
value: false,
},
retryOnFailure: {
value: false,
},
},
},
});
});
it('returns a valid definition for RECORD_CRUD.CREATE actions', () => {
expect(
getStepDefaultDefinition({
type: 'RECORD_CRUD.CREATE',
activeObjectMetadataItems: generatedMockObjectMetadataItems,
}),
).toStrictEqual({
id: expect.any(String),
name: 'Create Record',
type: 'RECORD_CRUD',
valid: false,
settings: {
input: {
type: 'CREATE',
objectName: generatedMockObjectMetadataItems[0].nameSingular,
objectRecord: {},
},
outputSchema: {},
errorHandlingOptions: {
continueOnFailure: {
value: false,
},
retryOnFailure: {
value: false,
},
},
},
});
});
it('returns a valid definition for RECORD_CRUD.UPDATE actions', () => {
expect(
getStepDefaultDefinition({
type: 'RECORD_CRUD.UPDATE',
activeObjectMetadataItems: generatedMockObjectMetadataItems,
}),
).toStrictEqual({
id: expect.any(String),
name: 'Update Record',
type: 'RECORD_CRUD',
valid: false,
settings: {
input: {
type: 'UPDATE',
objectName: generatedMockObjectMetadataItems[0].nameSingular,
objectRecord: {},
objectRecordId: '',
},
outputSchema: {},
errorHandlingOptions: {
continueOnFailure: {
value: false,
},
retryOnFailure: {
value: false,
},
},
},
});
});
it("throws for RECORD_CRUD.DELETE actions as it's not implemented yet", () => {
expect(() => {
getStepDefaultDefinition({
type: 'RECORD_CRUD.DELETE',
activeObjectMetadataItems: generatedMockObjectMetadataItems,
});
}).toThrow('Not implemented yet');
});
it('throws when providing an unknown type', () => {
expect(() => {
getStepDefaultDefinition({
type: 'unknown' as any,
activeObjectMetadataItems: generatedMockObjectMetadataItems,
});
}).toThrow('Unknown type: unknown');
});

View File

@ -0,0 +1,129 @@
import {
ArrayTypeNode,
ArrowFunction,
createSourceFile,
FunctionDeclaration,
LiteralTypeNode,
PropertySignature,
ScriptTarget,
StringLiteral,
SyntaxKind,
TypeNode,
UnionTypeNode,
VariableStatement,
} from 'typescript';
import { InputSchema, InputSchemaProperty } from '@/workflow/types/InputSchema';
import { isDefined } from 'twenty-ui';
const getTypeString = (typeNode: TypeNode): InputSchemaProperty => {
switch (typeNode.kind) {
case SyntaxKind.NumberKeyword:
return { type: 'number' };
case SyntaxKind.StringKeyword:
return { type: 'string' };
case SyntaxKind.BooleanKeyword:
return { type: 'boolean' };
case SyntaxKind.ArrayType:
return {
type: 'array',
items: getTypeString((typeNode as ArrayTypeNode).elementType),
};
case SyntaxKind.ObjectKeyword:
return { type: 'object' };
case SyntaxKind.TypeLiteral: {
const properties: InputSchema = {};
(typeNode as any).members.forEach((member: PropertySignature) => {
if (isDefined(member.name) && isDefined(member.type)) {
const memberName = (member.name as any).text;
properties[memberName] = getTypeString(member.type);
}
});
return { type: 'object', properties };
}
case SyntaxKind.UnionType: {
const unionNode = typeNode as UnionTypeNode;
const enumValues: string[] = [];
let isEnum = true;
unionNode.types.forEach((subType) => {
if (subType.kind === SyntaxKind.LiteralType) {
const literal = (subType as LiteralTypeNode).literal;
if (literal.kind === SyntaxKind.StringLiteral) {
enumValues.push((literal as StringLiteral).text);
} else {
isEnum = false;
}
} else {
isEnum = false;
}
});
if (isEnum) {
return { type: 'string', enum: enumValues };
}
return { type: 'unknown' };
}
default:
return { type: 'unknown' };
}
};
export const getFunctionInputSchema = (fileContent: string): InputSchema => {
const sourceFile = createSourceFile(
'temp.ts',
fileContent,
ScriptTarget.ESNext,
true,
);
const schema: InputSchema = {};
sourceFile.forEachChild((node) => {
if (node.kind === SyntaxKind.FunctionDeclaration) {
const funcNode = node as FunctionDeclaration;
const params = funcNode.parameters;
params.forEach((param) => {
const paramName = param.name.getText();
const typeNode = param.type;
if (isDefined(typeNode)) {
schema[paramName] = getTypeString(typeNode);
} else {
schema[paramName] = { type: 'unknown' };
}
});
} else if (node.kind === SyntaxKind.VariableStatement) {
const varStatement = node as VariableStatement;
varStatement.declarationList.declarations.forEach((declaration) => {
if (
isDefined(declaration.initializer) &&
declaration.initializer.kind === SyntaxKind.ArrowFunction
) {
const arrowFunction = declaration.initializer as ArrowFunction;
const params = arrowFunction.parameters;
params.forEach((param: any) => {
const paramName = param.name.text;
const typeNode = param.type;
if (isDefined(typeNode)) {
schema[paramName] = getTypeString(typeNode);
} else {
schema[paramName] = { type: 'unknown' };
}
});
}
});
}
});
return schema;
};

View File

@ -1,100 +0,0 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { WorkflowStep, WorkflowStepType } from '@/workflow/types/Workflow';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { v4 } from 'uuid';
const BASE_DEFAULT_STEP_SETTINGS = {
outputSchema: {},
errorHandlingOptions: {
continueOnFailure: {
value: false,
},
retryOnFailure: {
value: false,
},
},
};
export const getStepDefaultDefinition = ({
type,
activeObjectMetadataItems,
}: {
type: WorkflowStepType;
activeObjectMetadataItems: ObjectMetadataItem[];
}): WorkflowStep => {
const newStepId = v4();
switch (type) {
case 'CODE': {
return {
id: newStepId,
name: 'Code',
type: 'CODE',
valid: false,
settings: {
input: {
serverlessFunctionId: '',
serverlessFunctionVersion: '',
serverlessFunctionInput: {},
},
...BASE_DEFAULT_STEP_SETTINGS,
},
};
}
case 'SEND_EMAIL': {
return {
id: newStepId,
name: 'Send Email',
type: 'SEND_EMAIL',
valid: false,
settings: {
input: {
connectedAccountId: '',
email: '',
subject: '',
body: '',
},
...BASE_DEFAULT_STEP_SETTINGS,
},
};
}
case 'RECORD_CRUD.CREATE': {
return {
id: newStepId,
name: 'Create Record',
type: 'RECORD_CRUD',
valid: false,
settings: {
input: {
type: 'CREATE',
objectName: activeObjectMetadataItems[0].nameSingular,
objectRecord: {},
},
...BASE_DEFAULT_STEP_SETTINGS,
},
};
}
case 'RECORD_CRUD.UPDATE':
return {
id: newStepId,
name: 'Update Record',
type: 'RECORD_CRUD',
valid: false,
settings: {
input: {
type: 'UPDATE',
objectName: activeObjectMetadataItems[0].nameSingular,
objectRecordId: '',
objectRecord: {},
},
...BASE_DEFAULT_STEP_SETTINGS,
},
};
case 'RECORD_CRUD.DELETE': {
throw new Error('Not implemented yet');
}
default: {
return assertUnreachable(type, `Unknown type: ${type}`);
}
}
};