3675f264f1
## Context Logic functions can declare workflow inputs typed as records or arrays of records (e.g. the People Data Labs enrichment functions), but the workflow builder rendered those as a plain text input with a variable picker, which is not usable. ## What this does - Adds an `objectUniversalIdentifier` link on input schema properties, so a record-typed input is tied to a workspace object. - The SDK build infers it from a `TwentyRecord<'objectUniversalIdentifier'>` marker type in the handler signature, reading the object's universal identifier straight from the source; explicit input schemas can still set the field directly. - The workflow builder renders these inputs as a single record picker or a record multi-select with the variable picker on the right. Selected records are stored as record ids; `TwentyRecord<UID>` is a branded `string`, so the handler signature reflects that it receives ids (a bound variable resolves to whatever the referenced step produced). - The multi-select collapses overflowing chips into a `+N` badge (reusing `ExpandableList`) and its variable picker offers both record objects and fields. - Updates the People Data Labs enrichment inputs as the reference implementation. <img width="802" height="824" alt="CleanShot 2026-06-12 at 16 54 10@2x" src="https://github.com/user-attachments/assets/a0896d74-0aab-49bd-a173-14c578a2e533" /> <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21494?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
196 lines
5.3 KiB
TypeScript
196 lines
5.3 KiB
TypeScript
import {
|
|
type ArrayTypeNode,
|
|
type ArrowFunction,
|
|
createSourceFile,
|
|
type FunctionDeclaration,
|
|
type FunctionLikeDeclaration,
|
|
type Identifier,
|
|
type LiteralTypeNode,
|
|
type Node,
|
|
type PropertySignature,
|
|
ScriptTarget,
|
|
type StringLiteral,
|
|
SyntaxKind,
|
|
type TypeNode,
|
|
type TypeReferenceNode,
|
|
type UnionTypeNode,
|
|
type VariableStatement,
|
|
} from 'typescript';
|
|
|
|
import { type InputJsonSchema } from '@/logic-function';
|
|
import { isDefined } from '@/utils/validation/isDefined';
|
|
|
|
const TWENTY_RECORD_TYPE_NAME = 'TwentyRecord';
|
|
|
|
const getObjectUniversalIdentifierFromTypeArgument = (
|
|
typeReferenceNode: TypeReferenceNode,
|
|
): string | undefined => {
|
|
const typeArgument = typeReferenceNode.typeArguments?.[0];
|
|
|
|
if (
|
|
isDefined(typeArgument) &&
|
|
typeArgument.kind === SyntaxKind.LiteralType &&
|
|
(typeArgument as LiteralTypeNode).literal.kind === SyntaxKind.StringLiteral
|
|
) {
|
|
return ((typeArgument as LiteralTypeNode).literal as StringLiteral).text;
|
|
}
|
|
|
|
return undefined;
|
|
};
|
|
|
|
const buildArraySchemaFromItems = (items: InputJsonSchema): InputJsonSchema =>
|
|
items.type === 'record'
|
|
? {
|
|
type: 'records',
|
|
objectUniversalIdentifier: items.objectUniversalIdentifier,
|
|
}
|
|
: { type: 'array', items };
|
|
|
|
const getTypeString = (typeNode: TypeNode): InputJsonSchema => {
|
|
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 buildArraySchemaFromItems(
|
|
getTypeString((typeNode as ArrayTypeNode).elementType),
|
|
);
|
|
case SyntaxKind.TypeReference: {
|
|
const typeReferenceNode = typeNode as TypeReferenceNode;
|
|
const typeName =
|
|
typeReferenceNode.typeName.kind === SyntaxKind.Identifier
|
|
? (typeReferenceNode.typeName as Identifier).text
|
|
: undefined;
|
|
|
|
if (typeName === 'Array' || typeName === 'ReadonlyArray') {
|
|
const elementType = typeReferenceNode.typeArguments?.[0];
|
|
|
|
return buildArraySchemaFromItems(
|
|
isDefined(elementType) ? getTypeString(elementType) : {},
|
|
);
|
|
}
|
|
|
|
if (typeName === TWENTY_RECORD_TYPE_NAME) {
|
|
const objectUniversalIdentifier =
|
|
getObjectUniversalIdentifierFromTypeArgument(typeReferenceNode);
|
|
|
|
if (isDefined(objectUniversalIdentifier)) {
|
|
return { type: 'record', objectUniversalIdentifier };
|
|
}
|
|
}
|
|
|
|
return {};
|
|
}
|
|
case SyntaxKind.ObjectKeyword:
|
|
return { type: 'object' };
|
|
case SyntaxKind.TypeLiteral: {
|
|
const properties: InputJsonSchema['properties'] = {};
|
|
|
|
(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 {};
|
|
}
|
|
default:
|
|
return {};
|
|
}
|
|
};
|
|
|
|
const computeFunctionParameters = (
|
|
funcNode: FunctionDeclaration | FunctionLikeDeclaration | ArrowFunction,
|
|
schema: InputJsonSchema[],
|
|
): InputJsonSchema[] => {
|
|
const params = funcNode.parameters;
|
|
|
|
return params.reduce((updatedSchema, param) => {
|
|
const typeNode = param.type;
|
|
|
|
if (isDefined(typeNode)) {
|
|
return [...updatedSchema, getTypeString(typeNode)];
|
|
} else {
|
|
return [...updatedSchema, {}];
|
|
}
|
|
}, schema);
|
|
};
|
|
|
|
const extractFunctions = (node: Node): FunctionLikeDeclaration[] => {
|
|
if (node.kind === SyntaxKind.FunctionDeclaration) {
|
|
return [node as FunctionDeclaration];
|
|
}
|
|
|
|
if (node.kind === SyntaxKind.VariableStatement) {
|
|
const varStatement = node as VariableStatement;
|
|
|
|
return varStatement.declarationList.declarations
|
|
.filter(
|
|
(declaration) =>
|
|
isDefined(declaration.initializer) &&
|
|
declaration.initializer.kind === SyntaxKind.ArrowFunction,
|
|
)
|
|
.map((declaration) => declaration.initializer as ArrowFunction);
|
|
}
|
|
|
|
return [];
|
|
};
|
|
|
|
export const getFunctionInputSchema = (
|
|
fileContent: string,
|
|
): InputJsonSchema[] => {
|
|
const sourceFile = createSourceFile(
|
|
'temp.ts',
|
|
fileContent,
|
|
ScriptTarget.ESNext,
|
|
true,
|
|
);
|
|
let schema: InputJsonSchema[] = [];
|
|
|
|
sourceFile.forEachChild((node) => {
|
|
if (
|
|
node.kind === SyntaxKind.FunctionDeclaration ||
|
|
node.kind === SyntaxKind.VariableStatement
|
|
) {
|
|
const functions = extractFunctions(node);
|
|
|
|
functions.forEach((func) => {
|
|
schema = computeFunctionParameters(func, schema);
|
|
});
|
|
}
|
|
});
|
|
|
|
return schema;
|
|
};
|