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. -->
This commit is contained in:
Raphaël Bosi
2026-06-19 09:10:01 +02:00
committed by GitHub
parent 9eb90e72f9
commit 3675f264f1
47 changed files with 1861 additions and 168 deletions
@@ -0,0 +1,156 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { FormBooleanFieldInput } from '@/object-record/record-field/ui/form-types/components/FormBooleanFieldInput';
import { FormMultiRecordPicker } from '@/object-record/record-field/ui/form-types/components/FormMultiRecordPicker';
import { FormNumberFieldInput } from '@/object-record/record-field/ui/form-types/components/FormNumberFieldInput';
import { FormSelectFieldInput } from '@/object-record/record-field/ui/form-types/components/FormSelectFieldInput';
import { FormSingleRecordPicker } from '@/object-record/record-field/ui/form-types/components/FormSingleRecordPicker';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { type VariablePickerComponent } from '@/object-record/record-field/ui/form-types/types/VariablePickerComponent';
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import { getWorkflowCodeFieldsEnumSelectOptions } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/getWorkflowCodeFieldsEnumSelectOptions';
import { getWorkflowCodeFieldsLeafKind } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/getWorkflowCodeFieldsLeafKind';
import { t } from '@lingui/core/macro';
import {
isBoolean,
isNonEmptyArray,
isNonEmptyString,
isNull,
isNumber,
isString,
} from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { type InputSchemaProperty } from 'twenty-shared/workflow';
type WorkflowEditActionCodeFieldLeafProps = {
label: string;
inputValue: unknown;
schemaProperty?: InputSchemaProperty;
readonly?: boolean;
onChange: (value: unknown) => void;
VariablePicker?: VariablePickerComponent;
};
export const WorkflowEditActionCodeFieldLeaf = ({
label,
inputValue,
schemaProperty,
readonly,
onChange,
VariablePicker,
}: WorkflowEditActionCodeFieldLeafProps) => {
const { objectMetadataItems } = useObjectMetadataItems();
const leafKind = getWorkflowCodeFieldsLeafKind(schemaProperty);
if (leafKind === 'record' || leafKind === 'record-array') {
const objectUniversalIdentifier =
schemaProperty?.objectUniversalIdentifier ??
schemaProperty?.items?.objectUniversalIdentifier;
const recordObjectMetadataItem = isNonEmptyString(objectUniversalIdentifier)
? objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.universalIdentifier ===
objectUniversalIdentifier,
)
: undefined;
if (isDefined(recordObjectMetadataItem)) {
if (leafKind === 'record') {
return (
<FormSingleRecordPicker
label={label}
defaultValue={isNonEmptyString(inputValue) ? inputValue : undefined}
onChange={onChange}
objectNameSingulars={[recordObjectMetadataItem.nameSingular]}
disabled={readonly}
VariablePicker={VariablePicker}
shouldDisplayRecordFieldsInVariablePicker={true}
/>
);
}
return (
<FormMultiRecordPicker
label={label}
defaultValue={
Array.isArray(inputValue) ||
isString(inputValue) ||
isNull(inputValue)
? inputValue
: undefined
}
onChange={onChange}
objectNameSingular={recordObjectMetadataItem.nameSingular}
readonly={readonly}
VariablePicker={VariablePicker}
/>
);
}
}
if (leafKind === 'boolean') {
return (
<FormBooleanFieldInput
label={label}
defaultValue={
isBoolean(inputValue) || isStandaloneVariableString(inputValue)
? inputValue
: undefined
}
readonly={readonly}
onChange={onChange}
VariablePicker={VariablePicker}
/>
);
}
if (leafKind === 'number') {
return (
<FormNumberFieldInput
label={label}
defaultValue={
isNumber(inputValue) || isString(inputValue) ? inputValue : undefined
}
readonly={readonly}
onChange={onChange}
VariablePicker={VariablePicker}
/>
);
}
if (leafKind === 'enum' && isDefined(schemaProperty)) {
const enumOptions = getWorkflowCodeFieldsEnumSelectOptions(schemaProperty);
if (isNonEmptyArray(enumOptions)) {
return (
<FormSelectFieldInput
label={label}
defaultValue={
!isDefined(inputValue)
? undefined
: isString(inputValue)
? inputValue
: String(inputValue)
}
readonly={readonly}
onChange={onChange}
VariablePicker={VariablePicker}
options={enumOptions}
/>
);
}
}
return (
<FormTextFieldInput
label={label}
placeholder={t`Enter value`}
defaultValue={isDefined(inputValue) ? `${inputValue}` : ''}
readonly={readonly}
onChange={onChange}
VariablePicker={VariablePicker}
multiline={schemaProperty?.multiline === true}
/>
);
};
@@ -1,17 +1,12 @@
import { FormBooleanFieldInput } from '@/object-record/record-field/ui/form-types/components/FormBooleanFieldInput';
import { FormNestedFieldInputContainer } from '@/object-record/record-field/ui/form-types/components/FormNestedFieldInputContainer';
import { FormNumberFieldInput } from '@/object-record/record-field/ui/form-types/components/FormNumberFieldInput';
import { FormSelectFieldInput } from '@/object-record/record-field/ui/form-types/components/FormSelectFieldInput';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { type VariablePickerComponent } from '@/object-record/record-field/ui/form-types/types/VariablePickerComponent';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { WorkflowEditActionCodeFieldLeaf } from '@/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCodeFieldLeaf';
import { getInputSchemaPropertyAtPath } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/getInputSchemaPropertyAtPath';
import { getWorkflowCodeFieldsEnumSelectOptions } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/getWorkflowCodeFieldsEnumSelectOptions';
import { getWorkflowCodeFieldsLeafKind } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/getWorkflowCodeFieldsLeafKind';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards';
import { isDefined, isPlainObject } from 'twenty-shared/utils';
import { isNonEmptyString } from '@sniptt/guards';
import { isPlainObject } from 'twenty-shared/utils';
import { type FunctionInput, type InputSchema } from 'twenty-shared/workflow';
import { themeCssVariables } from 'twenty-ui/theme-constants';
@@ -59,7 +54,13 @@ export const WorkflowEditActionCodeFields = ({
? schemaProperty.label
: inputKey;
if (isPlainObject(inputValue)) {
const leafKind = getWorkflowCodeFieldsLeafKind(schemaProperty);
const isNestedObject =
isPlainObject(inputValue) &&
leafKind !== 'record' &&
leafKind !== 'record-array';
if (isNestedObject) {
return (
<div key={pathKey}>
<InputLabel>{displayLabel}</InputLabel>
@@ -78,83 +79,15 @@ export const WorkflowEditActionCodeFields = ({
);
}
const leafKind = getWorkflowCodeFieldsLeafKind(schemaProperty);
if (leafKind === 'boolean') {
return (
<FormBooleanFieldInput
key={pathKey}
label={displayLabel}
defaultValue={
!isDefined(inputValue)
? undefined
: typeof inputValue === 'boolean' ||
typeof inputValue === 'string'
? inputValue
: undefined
}
readonly={readonly}
onChange={(value) => onInputChange?.(value, currentPath)}
VariablePicker={VariablePicker}
/>
);
}
if (leafKind === 'number') {
return (
<FormNumberFieldInput
key={pathKey}
label={displayLabel}
defaultValue={
!isDefined(inputValue)
? undefined
: typeof inputValue === 'number' ||
typeof inputValue === 'string'
? inputValue
: undefined
}
readonly={readonly}
onChange={(value) => onInputChange?.(value, currentPath)}
VariablePicker={VariablePicker}
/>
);
}
if (leafKind === 'enum' && isDefined(schemaProperty)) {
const enumOptions =
getWorkflowCodeFieldsEnumSelectOptions(schemaProperty);
if (isNonEmptyArray(enumOptions)) {
return (
<FormSelectFieldInput
key={pathKey}
label={displayLabel}
defaultValue={
!isDefined(inputValue)
? undefined
: typeof inputValue === 'string'
? inputValue
: String(inputValue)
}
readonly={readonly}
onChange={(value) => onInputChange?.(value, currentPath)}
VariablePicker={VariablePicker}
options={enumOptions}
/>
);
}
}
return (
<FormTextFieldInput
<WorkflowEditActionCodeFieldLeaf
key={pathKey}
label={displayLabel}
placeholder={t`Enter value`}
defaultValue={inputValue ? `${inputValue}` : ''}
inputValue={inputValue}
schemaProperty={schemaProperty}
readonly={readonly}
onChange={(value) => onInputChange?.(value, currentPath)}
VariablePicker={VariablePicker}
multiline={schemaProperty?.multiline === true}
/>
);
})}
@@ -33,4 +33,44 @@ describe('getWorkflowCodeFieldsLeafKind', () => {
);
expect(getWorkflowCodeFieldsLeafKind(undefined)).toBe('text');
});
it('should map record/records types to record kinds', () => {
expect(
getWorkflowCodeFieldsLeafKind({
type: 'record',
objectUniversalIdentifier: 'company-universal-identifier',
}),
).toBe('record');
expect(
getWorkflowCodeFieldsLeafKind({
type: 'records',
objectUniversalIdentifier: 'person-universal-identifier',
}),
).toBe('record-array');
});
it('should map the legacy object/array+marker form to record kinds', () => {
expect(
getWorkflowCodeFieldsLeafKind({
type: 'object',
objectUniversalIdentifier: 'company-universal-identifier',
}),
).toBe('record');
expect(
getWorkflowCodeFieldsLeafKind({
type: 'array',
items: {
type: 'object',
objectUniversalIdentifier: 'person-universal-identifier',
},
}),
).toBe('record-array');
expect(getWorkflowCodeFieldsLeafKind({ type: 'object' })).toBe('text');
expect(
getWorkflowCodeFieldsLeafKind({
type: 'array',
items: { type: 'object' },
}),
).toBe('text');
});
});
@@ -48,4 +48,31 @@ describe('mergeDefaultFunctionInputAndFunctionInput', () => {
}),
).toEqual({ briefs: '["a", "b"]', b: null });
});
it('should preserve stored record values for record-typed inputs', () => {
const newInput = { company: null, people: [] };
const oldInput = {
company: '20202020-aaaa-4bbb-8ccc-111111111111',
people: ['20202020-aaaa-4bbb-8ccc-222222222222', '{{trigger.record.id}}'],
};
expect(
mergeDefaultFunctionInputAndFunctionInput({
newInput,
oldInput,
}),
).toEqual(oldInput);
});
it('should reset stale empty objects to null for record-typed inputs', () => {
const newInput = { company: null };
const oldInput = { company: {} };
expect(
mergeDefaultFunctionInputAndFunctionInput({
newInput,
oldInput,
}),
).toEqual({ company: null });
});
});
@@ -1,9 +1,19 @@
import { isNonEmptyArray } from '@sniptt/guards';
import {
isRecordArraySchema,
isRecordObjectSchema,
} from 'twenty-shared/logic-function';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type InputSchemaProperty } from 'twenty-shared/workflow';
type WorkflowCodeFieldsLeafKind = 'boolean' | 'enum' | 'number' | 'text';
type WorkflowCodeFieldsLeafKind =
| 'boolean'
| 'enum'
| 'number'
| 'record'
| 'record-array'
| 'text';
export const getWorkflowCodeFieldsLeafKind = (
property: InputSchemaProperty | undefined,
@@ -12,6 +22,14 @@ export const getWorkflowCodeFieldsLeafKind = (
return 'text';
}
if (isRecordObjectSchema(property)) {
return 'record';
}
if (isRecordArraySchema(property)) {
return 'record-array';
}
if (
(property.type === 'string' || property.type === FieldMetadataType.TEXT) &&
isNonEmptyArray(property.enum)