Files
twenty/packages/twenty-shared/src/logic-function/get-function-input-schema.ts
T
Raphaël Bosi 3675f264f1 Infer record pickers for record-typed logic function workflow inputs (#21494)
## 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. -->
2026-06-19 09:10:01 +02:00

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;
};