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:
+156
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+13
-80
@@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
+40
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+27
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
+19
-1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user