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,23 @@
import { defineLogicFunction, type TwentyRecord } from 'twenty-sdk/define';
const handler = async (params: {
companyId: TwentyRecord<'20202020-b374-4779-a561-80086cb2e17f'>;
postCardIds: TwentyRecord<'54b589ca-eeed-4950-a176-358418b85c05'>[];
}) => {
return {
companyId: params.companyId,
postCardCount: params.postCardIds.length,
};
};
export default defineLogicFunction({
universalIdentifier: 'a1b2c3d4-ac10-4a7b-8c9d-0e1f2a3b4c5d',
name: 'enrich-post-cards',
description: 'Enrich post cards of a company',
timeoutSeconds: 5,
handler,
workflowActionTriggerSettings: {
label: 'Enrich Post Cards',
icon: 'IconMail',
},
});
@@ -41,8 +41,6 @@ export const useLogicFunctionForm = ({
setFormValues((prevState: LogicFunctionFormValues) => ({
...prevState,
sourceHandlerCode: value as string,
// Re-infer schemas for any active surface so they stay in sync
// with the source code.
toolTriggerSettings: prevState.toolTriggerSettings
? {
...prevState.toolTriggerSettings,
@@ -5,11 +5,11 @@ import { FormFieldInputContainer } from '@/object-record/record-field/ui/form-ty
import { FormFieldInputInnerContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputInnerContainer';
import { FormFieldInputRowContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputRowContainer';
import { FormFieldPlaceholder } from '@/object-record/record-field/ui/form-types/components/FormFieldPlaceholder';
import { FormSingleRecordPicker } from '@/object-record/record-field/ui/form-types/components/FormSingleRecordPicker';
import {
FormSingleRecordPicker,
type RecordId,
type Variable,
} from '@/object-record/record-field/ui/form-types/components/FormSingleRecordPicker';
} from '@/object-record/record-field/ui/form-types/types/RecordPickerValue';
import { type VariablePickerComponent } from '@/object-record/record-field/ui/form-types/types/VariablePickerComponent';
import { ForbiddenFieldDisplay } from '@/object-record/record-field/ui/meta-types/display/components/ForbiddenFieldDisplay';
import { InputLabel } from '@/ui/input/components/InputLabel';
@@ -0,0 +1,87 @@
import { RecordChip } from '@/object-record/components/RecordChip';
import { FormFieldPlaceholder } from '@/object-record/record-field/ui/form-types/components/FormFieldPlaceholder';
import { VariableChipStandalone } from '@/object-record/record-field/ui/form-types/components/VariableChipStandalone';
import { type FormMultiRecordPickerDraftValue } from '@/object-record/record-field/ui/form-types/utils/getFormMultiRecordPickerDraftValue';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList';
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { isNonEmptyArray } from '@sniptt/guards';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledChipsContainer = styled.div`
align-items: center;
display: flex;
flex: 1;
gap: ${themeCssVariables.spacing[1]};
margin-inline: ${themeCssVariables.spacing[2]};
min-width: 0;
overflow: hidden;
`;
type FormMultiRecordFieldChipsProps = {
draftValue: FormMultiRecordPickerDraftValue;
selectedRecords: ObjectRecord[];
objectNameSingular: string;
readonly?: boolean;
onUnlinkVariable: () => void;
onRemoveStaticVariable: (variable: string) => void;
};
export const FormMultiRecordFieldChips = ({
draftValue,
selectedRecords,
objectNameSingular,
readonly,
onUnlinkVariable,
onRemoveStaticVariable,
}: FormMultiRecordFieldChipsProps) => {
if (draftValue.type === 'variable') {
return (
<StyledChipsContainer>
<VariableChipStandalone
rawVariableName={draftValue.value}
onRemove={readonly ? undefined : onUnlinkVariable}
isFullRecord
/>
</StyledChipsContainer>
);
}
const staticVariables = draftValue.value.filter((entry) =>
isStandaloneVariableString(entry),
);
if (!isNonEmptyArray(selectedRecords) && !isNonEmptyArray(staticVariables)) {
return (
<StyledChipsContainer>
<FormFieldPlaceholder>{t`Select`}</FormFieldPlaceholder>
</StyledChipsContainer>
);
}
const chips = [
...selectedRecords.map((record) => (
<RecordChip
key={record.id}
record={record}
objectNameSingular={objectNameSingular}
/>
)),
...staticVariables.map((variable) => (
<VariableChipStandalone
key={variable}
rawVariableName={variable}
onRemove={readonly ? undefined : () => onRemoveStaticVariable(variable)}
isFullRecord
/>
)),
];
return (
<StyledChipsContainer>
<ExpandableList isChipCountDisplayed={true}>{chips}</ExpandableList>
</StyledChipsContainer>
);
};
@@ -0,0 +1,258 @@
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { FormFieldInputContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputContainer';
import { FormFieldInputInnerContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputInnerContainer';
import { FormFieldInputRowContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputRowContainer';
import { FormMultiRecordFieldChips } from '@/object-record/record-field/ui/form-types/components/FormMultiRecordFieldChips';
import { useOpenFormMultiRecordPicker } from '@/object-record/record-field/ui/form-types/hooks/useOpenFormMultiRecordPicker';
import {
type RecordId,
type Variable,
} from '@/object-record/record-field/ui/form-types/types/RecordPickerValue';
import { type VariablePickerComponent } from '@/object-record/record-field/ui/form-types/types/VariablePickerComponent';
import {
type FormMultiRecordPickerDraftValue,
getFormMultiRecordPickerDraftValue,
} from '@/object-record/record-field/ui/form-types/utils/getFormMultiRecordPickerDraftValue';
import { MultipleRecordPicker } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPicker';
import { type RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { useContext, useId, useState } from 'react';
import { isNonEmptyArray } from '@sniptt/guards';
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import { mapArrayToObject } from '~/utils/array/mapArrayToObject';
import { IconChevronDown } from 'twenty-ui/icon';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
const StyledFormSelectContainerWrapper = styled.div<{ readonly?: boolean }>`
cursor: ${({ readonly }) => (readonly ? 'default' : 'pointer')};
display: flex;
height: 32px;
min-width: 0;
width: 100%;
`;
const StyledIconButton = styled.div`
display: flex;
padding-right: ${themeCssVariables.spacing[2]};
`;
const StyledDropdownContainer = styled.div`
display: flex;
flex: 1;
min-width: 0;
`;
const StyledVariablePickerContainer = styled.div`
display: flex;
flex-shrink: 0;
`;
export type FormMultiRecordPickerProps = {
label?: string;
defaultValue?: Array<RecordId | Variable> | Variable | string | null;
onChange: (value: Array<RecordId | Variable> | Variable) => void;
objectNameSingular: string;
readonly?: boolean;
testId?: string;
VariablePicker?: VariablePickerComponent;
};
export const FormMultiRecordPicker = ({
label,
defaultValue,
onChange,
objectNameSingular,
readonly,
testId,
VariablePicker,
}: FormMultiRecordPickerProps) => {
const { theme } = useContext(ThemeContext);
const [draftValue, setDraftValue] = useState<FormMultiRecordPickerDraftValue>(
getFormMultiRecordPickerDraftValue(defaultValue),
);
const componentId = useId();
const dropdownId = `form-multi-record-picker-${componentId}`;
const variablesDropdownId = `form-multi-record-picker-${componentId}-variables`;
const { closeDropdown } = useCloseDropdown();
const { enqueueWarningSnackBar } = useSnackBar();
const { openFormMultiRecordPicker } = useOpenFormMultiRecordPicker({
objectNameSingular,
});
const staticRecordIds =
draftValue.type === 'static'
? draftValue.value.filter((entry) => isValidUuid(entry))
: [];
const { records: selectedRecords } = useFindManyRecords({
objectNameSingular,
filter: { id: { in: staticRecordIds } },
limit: Math.min(staticRecordIds.length, QUERY_MAX_RECORDS),
skip: !isNonEmptyArray(staticRecordIds),
withSoftDeleted: true,
});
const selectedRecordsById = mapArrayToObject(
selectedRecords,
(record) => record.id,
);
const orderedSelectedRecords = staticRecordIds
.map((recordId) => selectedRecordsById[recordId])
.filter(isDefined);
const handleOpenDropdown = () => {
if (draftValue.type !== 'static') {
return;
}
openFormMultiRecordPicker({
pickerInstanceId: dropdownId,
selectedRecordIds: staticRecordIds,
selectedRecords: orderedSelectedRecords,
});
};
const handleMorphItemChange = (morphItem: RecordPickerPickableMorphItem) => {
if (draftValue.type !== 'static') {
return;
}
const valueWithoutRecord = draftValue.value.filter(
(entry) => entry !== morphItem.recordId,
);
if (morphItem.isSelected) {
const selectedRecordCount = valueWithoutRecord.filter((entry) =>
isValidUuid(entry),
).length;
if (selectedRecordCount >= QUERY_MAX_RECORDS) {
enqueueWarningSnackBar({
message: t`You can select at most ${QUERY_MAX_RECORDS} records.`,
});
return;
}
}
const updatedValue = morphItem.isSelected
? [...valueWithoutRecord, morphItem.recordId]
: valueWithoutRecord;
setDraftValue({ type: 'static', value: updatedValue });
onChange(updatedValue);
};
const handleVariableTagInsert = (variableName: string) => {
setDraftValue({ type: 'variable', value: variableName });
onChange(variableName);
};
const handleUnlinkVariable = () => {
setDraftValue({ type: 'static', value: [] });
onChange([]);
};
const handleRemoveStaticVariable = (variable: string) => {
if (draftValue.type !== 'static') {
return;
}
const updatedValue = draftValue.value.filter((entry) => entry !== variable);
setDraftValue({ type: 'static', value: updatedValue });
onChange(updatedValue);
};
const chips = (
<FormMultiRecordFieldChips
draftValue={draftValue}
selectedRecords={orderedSelectedRecords}
objectNameSingular={objectNameSingular}
readonly={readonly}
onUnlinkVariable={handleUnlinkVariable}
onRemoveStaticVariable={handleRemoveStaticVariable}
/>
);
return (
<FormFieldInputContainer data-testid={testId}>
{label ? <InputLabel>{label}</InputLabel> : null}
<FormFieldInputRowContainer>
{readonly || draftValue.type === 'variable' ? (
<StyledFormSelectContainerWrapper readonly={readonly}>
<FormFieldInputInnerContainer
formFieldInputInstanceId={componentId}
hasRightElement={false}
>
{chips}
</FormFieldInputInnerContainer>
</StyledFormSelectContainerWrapper>
) : (
<StyledDropdownContainer>
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="bottom-start"
clickableComponentWidth="100%"
onOpen={handleOpenDropdown}
dropdownOffset={{
y: parseInt(theme.spacing[1], 10),
}}
clickableComponent={
<StyledFormSelectContainerWrapper>
<FormFieldInputInnerContainer
formFieldInputInstanceId={componentId}
hasRightElement={isDefined(VariablePicker) && !readonly}
hoverable
preventFocusStackUpdate={true}
>
{chips}
<StyledIconButton>
<IconChevronDown
size={theme.icon.size.md}
color={theme.font.color.light}
/>
</StyledIconButton>
</FormFieldInputInnerContainer>
</StyledFormSelectContainerWrapper>
}
dropdownComponents={
<MultipleRecordPicker
componentInstanceId={dropdownId}
focusId={dropdownId}
onChange={handleMorphItemChange}
onSubmit={() => closeDropdown(dropdownId)}
onClickOutside={() => closeDropdown(dropdownId)}
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
/>
}
/>
</StyledDropdownContainer>
)}
{isDefined(VariablePicker) && !readonly && (
<StyledVariablePickerContainer>
<VariablePicker
instanceId={variablesDropdownId}
disabled={readonly}
onVariableSelect={handleVariableTagInsert}
shouldDisplayRecordObjects={true}
shouldDisplayRecordFields={true}
objectNameSingularsToSelect={[objectNameSingular]}
/>
</StyledVariablePickerContainer>
)}
</FormFieldInputRowContainer>
</FormFieldInputContainer>
);
};
@@ -3,7 +3,7 @@ import { FormFieldPlaceholder } from '@/object-record/record-field/ui/form-types
import {
type RecordId,
type Variable,
} from '@/object-record/record-field/ui/form-types/components/FormSingleRecordPicker';
} from '@/object-record/record-field/ui/form-types/types/RecordPickerValue';
import { VariableChipStandalone } from '@/object-record/record-field/ui/form-types/components/VariableChipStandalone';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
@@ -3,6 +3,10 @@ import { FormFieldInputContainer } from '@/object-record/record-field/ui/form-ty
import { FormFieldInputInnerContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputInnerContainer';
import { FormFieldInputRowContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputRowContainer';
import { FormSingleRecordFieldChip } from '@/object-record/record-field/ui/form-types/components/FormSingleRecordFieldChip';
import {
type RecordId,
type Variable,
} from '@/object-record/record-field/ui/form-types/types/RecordPickerValue';
import { type VariablePickerComponent } from '@/object-record/record-field/ui/form-types/types/VariablePickerComponent';
import { SingleRecordPicker } from '@/object-record/record-picker/single-record-picker/components/SingleRecordPicker';
import { singleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerSearchFilterComponentState';
@@ -25,6 +29,7 @@ const StyledFormSelectContainerWrapper = styled.div<{ readonly?: boolean }>`
cursor: ${({ readonly }) => (readonly ? 'default' : 'pointer')};
display: flex;
height: 32px;
min-width: 0;
width: 100%;
`;
@@ -33,8 +38,16 @@ const StyledIconButton = styled.div`
padding-right: ${themeCssVariables.spacing[2]};
`;
export type RecordId = string;
export type Variable = string;
const StyledDropdownContainer = styled.div`
display: flex;
flex: 1;
min-width: 0;
`;
const StyledVariablePickerContainer = styled.div`
display: flex;
flex-shrink: 0;
`;
type FormSingleRecordPickerValue =
| {
@@ -64,6 +77,7 @@ export type FormSingleRecordPickerProps = {
disabled?: boolean;
testId?: string;
VariablePicker?: VariablePickerComponent;
shouldDisplayRecordFieldsInVariablePicker?: boolean;
};
export const FormSingleRecordPicker = ({
@@ -78,6 +92,7 @@ export const FormSingleRecordPicker = ({
disabled,
testId,
VariablePicker,
shouldDisplayRecordFieldsInVariablePicker = false,
}: FormSingleRecordPickerProps) => {
const { theme } = useContext(ThemeContext);
@@ -200,64 +215,72 @@ export const FormSingleRecordPicker = ({
</FormFieldInputInnerContainer>
</StyledFormSelectContainerWrapper>
) : (
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="bottom-start"
clickableComponentWidth="100%"
onClose={handleCloseRelationPickerDropdown}
onOpen={handleOpenDropdown}
dropdownOffset={{
y: parseInt(theme.spacing[1], 10),
}}
clickableComponent={
<StyledFormSelectContainerWrapper>
<FormFieldInputInnerContainer
formFieldInputInstanceId={componentId}
hasRightElement={isDefined(VariablePicker) && !disabled}
hoverable
preventFocusStackUpdate={true}
>
<FormSingleRecordFieldChip
draftValue={draftValue}
selectedRecord={selectedRecord}
objectNameSingular={resolvedObjectNameSingular}
onRemove={handleUnlinkVariable}
disabled={disabled}
/>
<StyledIconButton>
<IconChevronDown
size={theme.icon.size.md}
color={theme.font.color.light}
<StyledDropdownContainer>
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="bottom-start"
clickableComponentWidth="100%"
onClose={handleCloseRelationPickerDropdown}
onOpen={handleOpenDropdown}
dropdownOffset={{
y: parseInt(theme.spacing[1], 10),
}}
clickableComponent={
<StyledFormSelectContainerWrapper>
<FormFieldInputInnerContainer
formFieldInputInstanceId={componentId}
hasRightElement={isDefined(VariablePicker) && !disabled}
hoverable
preventFocusStackUpdate={true}
>
<FormSingleRecordFieldChip
draftValue={draftValue}
selectedRecord={selectedRecord}
objectNameSingular={resolvedObjectNameSingular}
onRemove={handleUnlinkVariable}
disabled={disabled}
/>
</StyledIconButton>
</FormFieldInputInnerContainer>
</StyledFormSelectContainerWrapper>
}
dropdownComponents={
<SingleRecordPicker
focusId={dropdownId}
componentInstanceId={dropdownId}
EmptyIcon={IconForbid}
emptyLabel={t`No record`}
onCancel={() => closeDropdown(dropdownId)}
onCreate={isDefined(onCreate) ? handleCreateRecord : undefined}
onMorphItemSelected={handleMorphItemSelected}
objectNameSingulars={objectNameSingulars}
recordPickerInstanceId={dropdownId}
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
/>
}
/>
<StyledIconButton>
<IconChevronDown
size={theme.icon.size.md}
color={theme.font.color.light}
/>
</StyledIconButton>
</FormFieldInputInnerContainer>
</StyledFormSelectContainerWrapper>
}
dropdownComponents={
<SingleRecordPicker
focusId={dropdownId}
componentInstanceId={dropdownId}
EmptyIcon={IconForbid}
emptyLabel={t`No record`}
onCancel={() => closeDropdown(dropdownId)}
onCreate={
isDefined(onCreate) ? handleCreateRecord : undefined
}
onMorphItemSelected={handleMorphItemSelected}
objectNameSingulars={objectNameSingulars}
recordPickerInstanceId={dropdownId}
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
/>
}
/>
</StyledDropdownContainer>
)}
{isDefined(VariablePicker) && !disabled && (
<VariablePicker
instanceId={variablesDropdownId}
disabled={disabled}
onVariableSelect={handleVariableTagInsert}
shouldDisplayRecordObjects={true}
shouldDisplayRecordFields={false}
objectNameSingularsToSelect={objectNameSingulars}
/>
<StyledVariablePickerContainer>
<VariablePicker
instanceId={variablesDropdownId}
disabled={disabled}
onVariableSelect={handleVariableTagInsert}
shouldDisplayRecordObjects={true}
shouldDisplayRecordFields={
shouldDisplayRecordFieldsInVariablePicker
}
objectNameSingularsToSelect={objectNameSingulars}
/>
</StyledVariablePickerContainer>
)}
</FormFieldInputRowContainer>
</FormFieldInputContainer>
@@ -0,0 +1,136 @@
import { FormMultiRecordPicker } from '@/object-record/record-field/ui/form-types/components/FormMultiRecordPicker';
import { styled } from '@linaria/react';
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, fn, userEvent, within } from 'storybook/test';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { MOCKED_STEP_ID } from '~/testing/mock-data/workflow';
const meta: Meta<typeof FormMultiRecordPicker> = {
title: 'UI/Data/Field/Form/Input/FormMultiRecordPicker',
component: FormMultiRecordPicker,
parameters: {
msw: graphqlMocks,
},
args: {},
argTypes: {},
decorators: [
ObjectMetadataItemsDecorator,
ComponentDecorator,
WorkspaceDecorator,
SnackBarDecorator,
RouterDecorator,
],
};
export default meta;
type Story = StoryObj<typeof FormMultiRecordPicker>;
const StyledNarrowContainer = styled.div`
width: 480px;
`;
export const Default: Story = {
args: {
label: 'Companies',
defaultValue: ['123e4567-e89b-12d3-a456-426614174000'],
objectNameSingular: 'company',
onChange: fn(),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const label = await canvas.findByText('Companies');
expect(label).toBeVisible();
const dropdown = await canvas.findByRole('button');
expect(dropdown).toBeVisible();
await userEvent.click(dropdown);
},
};
export const WithManyRecords: Story = {
args: {
label: 'Companies',
defaultValue: [
'20202020-a000-4485-94de-70c2a98daef2',
'20202020-a018-492d-89de-f9cd4ee80437',
'20202020-a023-4180-9da1-6b417beacf0e',
'20202020-a026-43c0-b042-0123f72f6cf9',
'20202020-a026-47d2-9474-75fb625f5eb1',
'20202020-a02e-4e28-b4a9-6096b36e26df',
'20202020-a043-441a-b269-a2378afed31c',
'20202020-a045-4266-b9e4-0e7a0697322b',
'20202020-a045-4b32-8484-a6807e9e0d22',
'20202020-a048-4007-9024-3ac47b8484d5',
],
objectNameSingular: 'company',
onChange: fn(),
VariablePicker: () => <div>VariablePicker</div>,
},
decorators: [
(Story) => (
<StyledNarrowContainer>
<Story />
</StyledNarrowContainer>
),
],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText('Companies');
const hiddenChipCount = await canvas.findByText(/^\+\d+$/);
expect(hiddenChipCount).toBeVisible();
const variablePicker = await canvas.findByText('VariablePicker');
expect(variablePicker).toBeVisible();
},
};
export const WithVariable: Story = {
args: {
label: 'Companies',
defaultValue: `{{${MOCKED_STEP_ID}.companies}}`,
objectNameSingular: 'company',
onChange: fn(),
VariablePicker: () => <div>VariablePicker</div>,
},
decorators: [WorkflowStepDecorator],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText('Companies');
const variablePicker = await canvas.findByText('VariablePicker');
expect(variablePicker).toBeVisible();
},
};
export const Readonly: Story = {
args: {
label: 'Companies',
defaultValue: ['123e4567-e89b-12d3-a456-426614174000'],
objectNameSingular: 'company',
onChange: fn(),
readonly: true,
VariablePicker: () => <div>VariablePicker</div>,
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await canvas.findByText('Companies');
const dropdown = canvas.queryByRole('button');
expect(dropdown).not.toBeInTheDocument();
const variablePicker = canvas.queryByText('VariablePicker');
expect(variablePicker).not.toBeInTheDocument();
expect(args.onChange).not.toHaveBeenCalled();
},
};
@@ -0,0 +1,67 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useMultipleRecordPickerOpen } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerOpen';
import { useMultipleRecordPickerPerformSearch } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerPerformSearch';
import { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPickableMorphItemsComponentState';
import { multipleRecordPickerSearchableObjectMetadataItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchableObjectMetadataItemsComponentState';
import { type RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useStore } from 'jotai';
export const useOpenFormMultiRecordPicker = ({
objectNameSingular,
}: {
objectNameSingular: string;
}) => {
const store = useStore();
const { openMultipleRecordPicker } = useMultipleRecordPickerOpen();
const { performSearch } = useMultipleRecordPickerPerformSearch();
const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular });
const openFormMultiRecordPicker = ({
pickerInstanceId,
selectedRecordIds,
selectedRecords,
}: {
pickerInstanceId: string;
selectedRecordIds: string[];
selectedRecords: ObjectRecord[];
}) => {
openMultipleRecordPicker(pickerInstanceId);
const pickableMorphItems: RecordPickerPickableMorphItem[] =
selectedRecordIds.map((recordId) => ({
objectMetadataId: objectMetadataItem.id,
recordId,
isSelected: true,
isMatchingSearchFilter: true,
}));
for (const record of selectedRecords) {
store.set(recordStoreFamilyState.atomFamily(record.id), record);
}
store.set(
multipleRecordPickerPickableMorphItemsComponentState.atomFamily({
instanceId: pickerInstanceId,
}),
pickableMorphItems,
);
store.set(
multipleRecordPickerSearchableObjectMetadataItemsComponentState.atomFamily(
{ instanceId: pickerInstanceId },
),
[objectMetadataItem],
);
performSearch({
multipleRecordPickerInstanceId: pickerInstanceId,
forceSearchFilter: '',
forceSearchableObjectMetadataItems: [objectMetadataItem],
forcePickableMorphItems: pickableMorphItems,
});
};
return { openFormMultiRecordPicker };
};
@@ -0,0 +1,2 @@
export type RecordId = string;
export type Variable = string;
@@ -0,0 +1,80 @@
import { getFormMultiRecordPickerDraftValue } from '@/object-record/record-field/ui/form-types/utils/getFormMultiRecordPickerDraftValue';
describe('getFormMultiRecordPickerDraftValue', () => {
it('should keep arrays of record ids and variables as static values', () => {
expect(
getFormMultiRecordPickerDraftValue([
'20202020-aaaa-4bbb-8ccc-111111111111',
'{{trigger.record.id}}',
]),
).toEqual({
type: 'static',
value: ['20202020-aaaa-4bbb-8ccc-111111111111', '{{trigger.record.id}}'],
});
});
it('should map a standalone variable string to a variable value', () => {
expect(getFormMultiRecordPickerDraftValue('{{step1.companies}}')).toEqual({
type: 'variable',
value: '{{step1.companies}}',
});
});
it('should wrap a single record id string into an array', () => {
expect(
getFormMultiRecordPickerDraftValue(
'20202020-aaaa-4bbb-8ccc-111111111111',
),
).toEqual({
type: 'static',
value: ['20202020-aaaa-4bbb-8ccc-111111111111'],
});
});
it('should drop array entries that are neither record ids nor variables', () => {
expect(
getFormMultiRecordPickerDraftValue([
'20202020-aaaa-4bbb-8ccc-111111111111',
'not-a-uuid',
'{{step1.companies}}',
123 as unknown as string,
]),
).toEqual({
type: 'static',
value: ['20202020-aaaa-4bbb-8ccc-111111111111', '{{step1.companies}}'],
});
});
it('should keep only record ids and variables from a JSON array string', () => {
expect(
getFormMultiRecordPickerDraftValue(
'["20202020-aaaa-4bbb-8ccc-111111111111", "{{step1.companies}}", "junk"]',
),
).toEqual({
type: 'static',
value: ['20202020-aaaa-4bbb-8ccc-111111111111', '{{step1.companies}}'],
});
});
it('should degrade a JSON array of plain strings to an empty selection', () => {
expect(getFormMultiRecordPickerDraftValue('["a", "b"]')).toEqual({
type: 'static',
value: [],
});
});
it('should degrade legacy free text, null and undefined to an empty selection', () => {
expect(getFormMultiRecordPickerDraftValue('some free text')).toEqual({
type: 'static',
value: [],
});
expect(getFormMultiRecordPickerDraftValue(null)).toEqual({
type: 'static',
value: [],
});
expect(getFormMultiRecordPickerDraftValue(undefined)).toEqual({
type: 'static',
value: [],
});
});
});
@@ -0,0 +1,65 @@
import {
type RecordId,
type Variable,
} from '@/object-record/record-field/ui/form-types/types/RecordPickerValue';
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import { isArray, isNonEmptyString, isString } from '@sniptt/guards';
import { isValidUuid } from 'twenty-shared/utils';
export type FormMultiRecordPickerDraftValue =
| {
type: 'static';
value: Array<RecordId | Variable>;
}
| {
type: 'variable';
value: Variable;
};
const keepRecordIdsAndVariables = (
entries: unknown[],
): Array<RecordId | Variable> =>
entries.filter(
(entry): entry is string =>
isString(entry) &&
(isValidUuid(entry) || isStandaloneVariableString(entry)),
);
export const getFormMultiRecordPickerDraftValue = (
defaultValue:
| Array<RecordId | Variable>
| Variable
| string
| null
| undefined,
): FormMultiRecordPickerDraftValue => {
if (isArray(defaultValue)) {
return {
type: 'static',
value: keepRecordIdsAndVariables(defaultValue),
};
}
if (isNonEmptyString(defaultValue)) {
if (isStandaloneVariableString(defaultValue)) {
return { type: 'variable', value: defaultValue };
}
if (isValidUuid(defaultValue)) {
return { type: 'static', value: [defaultValue] };
}
try {
const parsedValue = JSON.parse(defaultValue);
if (isArray(parsedValue)) {
return {
type: 'static',
value: keepRecordIdsAndVariables(parsedValue),
};
}
} catch {}
}
return { type: 'static', value: [] };
};
@@ -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)
+4
View File
@@ -32,6 +32,10 @@ Full documentation is available at **[docs.twenty.com/developers/extend/apps](ht
- [Building Apps](https://docs.twenty.com/developers/extend/apps/building) — entity definitions, API clients, testing, CLI reference
- [Publishing](https://docs.twenty.com/developers/extend/apps/publishing) — deploy, npm publish, marketplace
Guides in this repository:
- [Logic function inputs](./docs/logic-function-inputs.md) — input schema inference, record-typed inputs, and the id contract
## Manual installation
If you are adding `twenty-sdk` to an existing project instead of using `create-twenty-app`:
@@ -0,0 +1,66 @@
# Logic function inputs
When a logic function opts into the workflow action or AI tool surface but does
not declare an explicit `inputSchema`, the SDK infers one from the handler's
parameter type during the manifest build. The workflow builder uses that schema
to render an input form, and record-typed inputs render as record pickers.
## How inference works
Inference reads the handler's single `params` object type and maps each property:
- `string` / `number` / `boolean` map to the matching scalar input.
- String literal unions (`'a' | 'b'`) map to a select input.
- `T[]` / `Array<T>` map to array inputs.
- `TwentyRecord<'objectUniversalIdentifier'>` maps to a record input (see below).
Inference runs only when the trigger settings omit `inputSchema`. Providing an
explicit `inputSchema` disables inference for that surface entirely — this is the
escape hatch when a handler type cannot be expressed inline.
## Record-typed inputs
To bind an input to a workspace object, type it with `TwentyRecord`, passing the
object's universal identifier as a string literal:
```ts
import { defineLogicFunction, type TwentyRecord } from 'twenty-sdk/define';
const handler = async (params: {
companyId: TwentyRecord<'20202020-b374-4779-a561-80086cb2e17f'>;
postCardIds: TwentyRecord<'54b589ca-eeed-4950-a176-358418b85c05'>[];
}) => {
return {
companyId: params.companyId,
postCardCount: params.postCardIds.length,
};
};
```
The universal identifier is the source of truth and is read directly from the
literal — there is no name matching, so an unrelated type can never be mistaken
for a record.
- **Standard objects**: get the identifier from `STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS`
(exported from `twenty-sdk/define`), e.g.
`STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier`.
- **App objects**: use the `universalIdentifier` you set on the object's
`defineObject(...)`.
Only a string-literal argument resolves. `TwentyRecord` with no argument, or with
a non-literal argument, is treated as an unknown input.
## What the handler receives
`TwentyRecord<TUid>` is a branded `string`: `companyId` is a record id, and
`postCardIds` is an array of record ids. This matches what the runtime delivers —
the workflow action passes the selected record ids (or the value a bound
`{{variable}}` resolves to) straight to the handler. Handlers must therefore
accept ids. The People Data Labs functions model this:
```ts
export type RecordInput = string | { id?: string | null };
```
and normalize the input with an `extractRecordIds` helper before use. If a
handler needs full records, it fetches them by id with the Core API client.
@@ -1659,6 +1659,37 @@ export const EXPECTED_MANIFEST: Manifest = {
},
universalIdentifier: 'a1b2c3d4-1001-4a7b-8c9d-0e1f2a3b4c5d',
},
{
builtHandlerChecksum: '[checksum]',
builtHandlerPath: 'src/logic-functions/enrich-post-cards.function.mjs',
description: 'Enrich post cards of a company',
handlerName: 'default.config.handler',
name: 'enrich-post-cards',
sourceHandlerPath: 'src/logic-functions/enrich-post-cards.function.ts',
timeoutSeconds: 5,
workflowActionTriggerSettings: {
label: 'Enrich Post Cards',
icon: 'IconMail',
inputSchema: [
{
type: 'object',
properties: {
companyId: {
type: 'record',
objectUniversalIdentifier:
'20202020-b374-4779-a561-80086cb2e17f',
},
postCardIds: {
type: 'records',
objectUniversalIdentifier:
'54b589ca-eeed-4950-a176-358418b85c05',
},
},
},
],
},
universalIdentifier: 'a1b2c3d4-ac10-4a7b-8c9d-0e1f2a3b4c5d',
},
{
builtHandlerChecksum: '[checksum]',
builtHandlerPath: 'src/logic-functions/on-post-card-created.function.mjs',
@@ -27,6 +27,8 @@ export const defineEntitiesTests = (appPath: string): void => {
'src/components/test.front-component.mjs',
'src/components/test.front-component.mjs.map',
'src/logic-functions',
'src/logic-functions/enrich-post-cards.function.mjs',
'src/logic-functions/enrich-post-cards.function.mjs.map',
'src/logic-functions/greeting.function.mjs',
'src/logic-functions/greeting.function.mjs.map',
'src/logic-functions/lookup-recipient.function.mjs',
@@ -20,7 +20,7 @@ export const defineManifestTests = (appPath: string): void => {
expect(manifest).not.toBeNull();
expect(manifest.objects).toHaveLength(4);
expect(manifest.logicFunctions).toHaveLength(6);
expect(manifest.logicFunctions).toHaveLength(7);
expect(manifest.frontComponents).toHaveLength(4);
expect(manifest.roles).toHaveLength(2);
expect(manifest.fields).toHaveLength(23);
@@ -0,0 +1,41 @@
import { RICH_APP_PATH } from '@/cli/__tests__/apps/fixture-paths';
import { buildManifest } from '@/cli/utilities/build/manifest/manifest-build';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
const ENRICH_POST_CARDS_FUNCTION_UNIVERSAL_IDENTIFIER =
'a1b2c3d4-ac10-4a7b-8c9d-0e1f2a3b4c5d';
const POST_CARD_OBJECT_UNIVERSAL_IDENTIFIER =
'54b589ca-eeed-4950-a176-358418b85c05';
describe('buildManifest logic function input schema inference', () => {
it('resolves record-typed inputs to standard and app object universal identifiers', async () => {
const { manifest, errors } = await buildManifest(RICH_APP_PATH);
expect(errors).toEqual([]);
expect(manifest).not.toBeNull();
const logicFunction = manifest?.logicFunctions.find(
(entry) =>
entry.universalIdentifier ===
ENRICH_POST_CARDS_FUNCTION_UNIVERSAL_IDENTIFIER,
);
expect(logicFunction).toBeDefined();
expect(logicFunction?.workflowActionTriggerSettings?.inputSchema).toEqual([
{
type: 'object',
properties: {
companyId: {
type: 'record',
objectUniversalIdentifier:
STANDARD_OBJECTS.company.universalIdentifier,
},
postCardIds: {
type: 'records',
objectUniversalIdentifier: POST_CARD_OBJECT_UNIVERSAL_IDENTIFIER,
},
},
},
]);
}, 60000);
});
@@ -279,11 +279,6 @@ export const buildManifest = async (
const { handler: _, ...rest } = extract.config;
const relativeFilePath = relative(appPath, filePath);
// Auto-infer inputSchema for any trigger that opts in but omits one.
// For the AI tool surface we use the JSON schema directly; for the
// workflow action surface we convert to Twenty's InputSchema.
const inferredJsonSchema =
(rest.toolTriggerSettings && !rest.toolTriggerSettings.inputSchema) ||
(rest.workflowActionTriggerSettings &&
@@ -319,8 +314,8 @@ export const buildManifest = async (
? { workflowActionTriggerSettings }
: {}),
handlerName: 'default.config.handler',
sourceHandlerPath: relativeFilePath,
builtHandlerPath: relativeFilePath.replace(/\.tsx?$/, '.mjs'),
sourceHandlerPath: relativePath,
builtHandlerPath: relativePath.replace(/\.tsx?$/, '.mjs'),
builtHandlerChecksum: '[default-checksum]',
};
@@ -114,6 +114,7 @@ export {
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS as STANDARD_OBJECT,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from '@/sdk/define/objects/standard-object-ids';
export type { TwentyRecord } from '@/sdk/define/objects/twenty-record.type';
export { definePageLayout } from '@/sdk/define/page-layouts/define-page-layout';
export { definePageLayoutTab } from '@/sdk/define/page-layouts/define-page-layout-tab';
@@ -0,0 +1,2 @@
export type TwentyRecord<TObjectUniversalIdentifier extends string = string> =
string & { readonly __object?: TObjectUniversalIdentifier };
@@ -1,7 +1,10 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { DEFAULT_TOOL_INPUT_SCHEMA } from 'twenty-shared/logic-function';
import {
buildToolInputJsonSchema,
DEFAULT_TOOL_INPUT_SCHEMA,
} from 'twenty-shared/logic-function';
import { type GenerateDescriptorOptions } from 'src/engine/core-modules/tool-provider/interfaces/generate-descriptor-options.type';
import { type ToolProvider } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
@@ -46,14 +49,18 @@ export class LogicFunctionToolProvider implements ToolProvider {
): Promise<(ToolIndexEntry | ToolDescriptor)[]> {
const includeSchemas = options?.includeSchemas ?? true;
const { flatLogicFunctionMaps } =
const { flatLogicFunctionMaps, flatObjectMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId: context.workspaceId,
flatMapsKeys: ['flatLogicFunctionMaps'],
flatMapsKeys: ['flatLogicFunctionMaps', 'flatObjectMetadataMaps'],
},
);
const resolveObjectLabel = (objectUniversalIdentifier: string) =>
flatObjectMetadataMaps.byUniversalIdentifier[objectUniversalIdentifier]
?.labelSingular;
const logicFunctionsWithSchema = Object.values(
flatLogicFunctionMaps.byUniversalIdentifier,
).filter(
@@ -83,9 +90,12 @@ export class LogicFunctionToolProvider implements ToolProvider {
if (includeSchemas) {
descriptors.push({
...base,
inputSchema:
(logicFunction.toolTriggerSettings?.inputSchema as object) ??
DEFAULT_TOOL_INPUT_SCHEMA,
inputSchema: isDefined(logicFunction.toolTriggerSettings?.inputSchema)
? (buildToolInputJsonSchema(
logicFunction.toolTriggerSettings.inputSchema,
resolveObjectLabel,
) as object)
: DEFAULT_TOOL_INPUT_SCHEMA,
});
} else {
descriptors.push(base);
@@ -5,6 +5,8 @@ export type InputSchemaPropertyType =
| 'boolean'
| 'object'
| 'array'
| 'record'
| 'records'
| 'unknown'
| FieldMetadataType;
@@ -13,6 +15,7 @@ export type InputSchemaProperty = {
enum?: string[];
items?: InputSchemaProperty; // used to describe array type elements
properties?: Properties; // used to describe object type elements
objectUniversalIdentifier?: string;
};
type Properties = {
@@ -0,0 +1,154 @@
import { buildToolInputJsonSchema } from '@/logic-function/build-tool-input-json-schema';
describe('buildToolInputJsonSchema', () => {
it('should collapse a record-typed object to an id string with a resolved label', () => {
const result = buildToolInputJsonSchema(
{
type: 'object',
objectUniversalIdentifier: 'company-universal-identifier',
},
(universalIdentifier) =>
universalIdentifier === 'company-universal-identifier'
? 'Company'
: undefined,
);
expect(result).toEqual({
type: 'string',
description: 'Id of the Company record',
});
});
it('should fall back to a generic label when none resolves', () => {
expect(
buildToolInputJsonSchema({
type: 'object',
objectUniversalIdentifier: 'company-universal-identifier',
}),
).toEqual({
type: 'string',
description: 'Id of the linked record',
});
});
it('should collapse arrays of records to arrays of id strings', () => {
const result = buildToolInputJsonSchema(
{
type: 'array',
items: {
type: 'object',
objectUniversalIdentifier: 'person-universal-identifier',
},
},
() => 'Person',
);
expect(result).toEqual({
type: 'array',
items: { type: 'string', description: 'Id of the Person record' },
});
});
it('should collapse a record type to an id string', () => {
expect(
buildToolInputJsonSchema(
{
type: 'record',
objectUniversalIdentifier: 'company-universal-identifier',
},
() => 'Company',
),
).toEqual({ type: 'string', description: 'Id of the Company record' });
});
it('should collapse a records type to an array of id strings', () => {
expect(
buildToolInputJsonSchema(
{
type: 'records',
objectUniversalIdentifier: 'person-universal-identifier',
},
() => 'Person',
),
).toEqual({
type: 'array',
items: { type: 'string', description: 'Id of the Person record' },
});
});
it('should strip custom keywords recursively while keeping standard ones', () => {
const result = buildToolInputJsonSchema(
{
type: 'object',
label: 'Params',
properties: {
company: {
type: 'object',
objectUniversalIdentifier: 'company-universal-identifier',
description: 'A company',
},
people: {
type: 'array',
label: 'People',
items: {
type: 'object',
objectUniversalIdentifier: 'person-universal-identifier',
},
},
note: { type: 'string', multiline: true },
kind: { type: 'string', enum: ['a', 'b'] },
},
required: ['company'],
},
() => 'Company',
);
expect(result).toEqual({
type: 'object',
properties: {
company: { type: 'string', description: 'Id of the Company record' },
people: {
type: 'array',
items: { type: 'string', description: 'Id of the Company record' },
},
note: { type: 'string' },
kind: { type: 'string', enum: ['a', 'b'] },
},
required: ['company'],
});
});
it('should keep boolean additionalProperties and sanitize object ones', () => {
expect(
buildToolInputJsonSchema({
type: 'object',
additionalProperties: false,
}),
).toEqual({ type: 'object', additionalProperties: false });
expect(
buildToolInputJsonSchema({
type: 'object',
additionalProperties: { type: 'string', multiline: true },
}),
).toEqual({ type: 'object', additionalProperties: { type: 'string' } });
});
it('should leave non-record nodes untouched aside from custom keywords', () => {
expect(
buildToolInputJsonSchema({
type: 'object',
properties: {
plainObject: { type: 'object' },
count: { type: 'number', minimum: 0, maximum: 10 },
},
}),
).toEqual({
type: 'object',
properties: {
plainObject: { type: 'object' },
count: { type: 'number', minimum: 0, maximum: 10 },
},
});
});
});
@@ -86,6 +86,79 @@ describe('getFunctionInputSchema', () => {
]);
});
it('should resolve TwentyRecord markers to record schemas', () => {
const fileContent = `
export const main = (params: {
company: TwentyRecord<'company-universal-identifier'>;
companies: TwentyRecord<'company-universal-identifier'>[];
otherCompanies: Array<TwentyRecord<'company-universal-identifier'>>;
}): void => {
return;
};
`;
const result = getFunctionInputSchema(fileContent);
expect(result).toEqual([
{
type: 'object',
properties: {
company: {
type: 'record',
objectUniversalIdentifier: 'company-universal-identifier',
},
companies: {
type: 'records',
objectUniversalIdentifier: 'company-universal-identifier',
},
otherCompanies: {
type: 'records',
objectUniversalIdentifier: 'company-universal-identifier',
},
},
},
]);
});
it('should leave a bare TwentyRecord without a literal argument unresolved', () => {
const fileContent = `
export const main = (params: { record: TwentyRecord }): void => {
return;
};
`;
const result = getFunctionInputSchema(fileContent);
expect(result).toEqual([
{
type: 'object',
properties: {
record: {},
},
},
]);
});
it('should not resolve plain object type references to records', () => {
const fileContent = `
export const main = (params: {
company: Company;
companies: Company[];
}): void => {
return;
};
`;
const result = getFunctionInputSchema(fileContent);
expect(result).toEqual([
{
type: 'object',
properties: {
company: {},
companies: { type: 'array', items: {} },
},
},
]);
});
it('should analyze a complex function correctly', () => {
const fileContent = `
function testFunction(
@@ -18,6 +18,29 @@ describe('getInputSchemaFromSourceCode', () => {
const result = await getInputSchemaFromSourceCode(fileContent);
expect(result).toEqual(DEFAULT_TOOL_INPUT_SCHEMA);
});
it('should fall back to empty when the params type cannot be inferred', async () => {
const fileContent =
'function testFunction(params: ImportedAlias) { return }';
const result = await getInputSchemaFromSourceCode(fileContent);
expect(result).toEqual(DEFAULT_TOOL_INPUT_SCHEMA);
});
it('should infer record schemas from TwentyRecord markers', async () => {
const fileContent = `
function testFunction(params: {
companies: TwentyRecord<'company-universal-identifier'>[];
}) { return }
`;
const result = await getInputSchemaFromSourceCode(fileContent);
expect(result).toEqual({
type: 'object',
properties: {
companies: {
type: 'records',
objectUniversalIdentifier: 'company-universal-identifier',
},
},
});
});
it('should return input from source code', async () => {
const fileContent = `
function testFunction(
@@ -0,0 +1,39 @@
import { isRecordArraySchema } from '@/logic-function/is-record-array-schema';
describe('isRecordArraySchema', () => {
it('returns true for a records schema', () => {
expect(
isRecordArraySchema({
type: 'records',
objectUniversalIdentifier: 'person-universal-identifier',
}),
).toBe(true);
});
it('returns false for a records schema without an objectUniversalIdentifier', () => {
expect(isRecordArraySchema({ type: 'records' })).toBe(false);
});
it('returns true for the legacy array of record objects', () => {
expect(
isRecordArraySchema({
type: 'array',
items: {
type: 'object',
objectUniversalIdentifier: 'person-universal-identifier',
},
}),
).toBe(true);
});
it('returns false for an array of plain objects', () => {
expect(
isRecordArraySchema({ type: 'array', items: { type: 'object' } }),
).toBe(false);
});
it('returns false for non-array schemas and nullish input', () => {
expect(isRecordArraySchema({ type: 'object' })).toBe(false);
expect(isRecordArraySchema(undefined)).toBe(false);
});
});
@@ -0,0 +1,43 @@
import { isRecordObjectSchema } from '@/logic-function/is-record-object-schema';
describe('isRecordObjectSchema', () => {
it('returns true for a record schema', () => {
expect(
isRecordObjectSchema({
type: 'record',
objectUniversalIdentifier: 'company-universal-identifier',
}),
).toBe(true);
});
it('returns true for the legacy object+marker schema', () => {
expect(
isRecordObjectSchema({
type: 'object',
objectUniversalIdentifier: 'company-universal-identifier',
}),
).toBe(true);
});
it('returns false for a record schema without an objectUniversalIdentifier', () => {
expect(isRecordObjectSchema({ type: 'record' })).toBe(false);
});
it('returns false for a plain object schema', () => {
expect(isRecordObjectSchema({ type: 'object' })).toBe(false);
expect(
isRecordObjectSchema({ type: 'object', objectUniversalIdentifier: '' }),
).toBe(false);
});
it('returns false for non-object schemas and nullish input', () => {
expect(
isRecordObjectSchema({
type: 'string',
objectUniversalIdentifier: 'company-universal-identifier',
}),
).toBe(false);
expect(isRecordObjectSchema(undefined)).toBe(false);
expect(isRecordObjectSchema(null)).toBe(false);
});
});
@@ -140,6 +140,62 @@ describe('jsonSchemaToInputSchema', () => {
});
});
it('preserves objectUniversalIdentifier on object properties and array items', () => {
const result = jsonSchemaToInputSchema({
type: 'object',
properties: {
company: {
type: 'object',
objectUniversalIdentifier: 'company-universal-identifier',
},
people: {
type: 'array',
items: {
type: 'object',
objectUniversalIdentifier: 'person-universal-identifier',
},
},
},
});
expect(result[0].properties?.company).toEqual({
type: 'object',
objectUniversalIdentifier: 'company-universal-identifier',
});
expect(result[0].properties?.people).toEqual({
type: 'array',
items: {
type: 'object',
objectUniversalIdentifier: 'person-universal-identifier',
},
});
});
it('passes record/records types through with objectUniversalIdentifier', () => {
const result = jsonSchemaToInputSchema({
type: 'object',
properties: {
company: {
type: 'record',
objectUniversalIdentifier: 'company-universal-identifier',
},
people: {
type: 'records',
objectUniversalIdentifier: 'person-universal-identifier',
},
},
});
expect(result[0].properties?.company).toEqual({
type: 'record',
objectUniversalIdentifier: 'company-universal-identifier',
});
expect(result[0].properties?.people).toEqual({
type: 'records',
objectUniversalIdentifier: 'person-universal-identifier',
});
});
it('does not set label when empty or omitted', () => {
const result = jsonSchemaToInputSchema({
type: 'object',
@@ -0,0 +1,81 @@
import { isNonEmptyString, isObject } from '@sniptt/guards';
import { type InputJsonSchema } from '@/logic-function/input-json-schema.type';
import { isRecordObjectSchema } from '@/logic-function/is-record-object-schema';
import { isDefined } from '@/utils/validation/isDefined';
type ResolveObjectLabel = (
objectUniversalIdentifier: string,
) => string | undefined;
const buildRecordIdDescription = (
objectUniversalIdentifier: string | undefined,
resolveObjectLabel?: ResolveObjectLabel,
): string => {
const objectLabel = isNonEmptyString(objectUniversalIdentifier)
? resolveObjectLabel?.(objectUniversalIdentifier)
: undefined;
return `Id of the ${isNonEmptyString(objectLabel) ? objectLabel : 'linked'} record`;
};
export const buildToolInputJsonSchema = (
jsonSchema: InputJsonSchema,
resolveObjectLabel?: ResolveObjectLabel,
): InputJsonSchema => {
if (isRecordObjectSchema(jsonSchema)) {
return {
type: 'string',
description: buildRecordIdDescription(
jsonSchema.objectUniversalIdentifier,
resolveObjectLabel,
),
};
}
if (jsonSchema.type === 'records') {
return {
type: 'array',
items: {
type: 'string',
description: buildRecordIdDescription(
jsonSchema.objectUniversalIdentifier,
resolveObjectLabel,
),
},
};
}
const {
objectUniversalIdentifier: _objectUniversalIdentifier,
multiline: _multiline,
label: _label,
items,
properties,
additionalProperties,
...standardKeywords
} = jsonSchema;
const toolSchema: InputJsonSchema = { ...standardKeywords };
if (isDefined(items)) {
toolSchema.items = buildToolInputJsonSchema(items, resolveObjectLabel);
}
if (isDefined(properties)) {
toolSchema.properties = Object.fromEntries(
Object.entries(properties).map(([key, value]) => [
key,
buildToolInputJsonSchema(value, resolveObjectLabel),
]),
);
}
if (isDefined(additionalProperties)) {
toolSchema.additionalProperties = isObject(additionalProperties)
? buildToolInputJsonSchema(additionalProperties, resolveObjectLabel)
: additionalProperties;
}
return toolSchema;
};
@@ -20,6 +20,32 @@ import {
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:
@@ -29,10 +55,9 @@ const getTypeString = (typeNode: TypeNode): InputJsonSchema => {
case SyntaxKind.BooleanKeyword:
return { type: 'boolean' };
case SyntaxKind.ArrayType:
return {
type: 'array',
items: getTypeString((typeNode as ArrayTypeNode).elementType),
};
return buildArraySchemaFromItems(
getTypeString((typeNode as ArrayTypeNode).elementType),
);
case SyntaxKind.TypeReference: {
const typeReferenceNode = typeNode as TypeReferenceNode;
const typeName =
@@ -43,10 +68,18 @@ const getTypeString = (typeNode: TypeNode): InputJsonSchema => {
if (typeName === 'Array' || typeName === 'ReadonlyArray') {
const elementType = typeReferenceNode.typeArguments?.[0];
return {
type: 'array',
items: isDefined(elementType) ? getTypeString(elementType) : {},
};
return buildArraySchemaFromItems(
isDefined(elementType) ? getTypeString(elementType) : {},
);
}
if (typeName === TWENTY_RECORD_TYPE_NAME) {
const objectUniversalIdentifier =
getObjectUniversalIdentifierFromTypeArgument(typeReferenceNode);
if (isDefined(objectUniversalIdentifier)) {
return { type: 'record', objectUniversalIdentifier };
}
}
return {};
@@ -11,7 +11,6 @@ export const getInputSchemaFromSourceCode = async (
await import('./get-function-input-schema');
const inputSchema = getFunctionInputSchema(sourceCode);
// Logic functions take a single params object
const firstParam = inputSchema[0];
if (firstParam?.type === 'object' && isDefined(firstParam.properties)) {
@@ -7,6 +7,7 @@
* |___/
*/
export { buildToolInputJsonSchema } from './build-tool-input-json-schema';
export { DEFAULT_TOOL_INPUT_SCHEMA } from './constants/DefaultToolInputSchema';
export { SEED_WORKFLOW_ACTION_TRIGGER_SETTINGS } from './constants/SeedWorkflowActionTriggerSettings';
export { getInputSchemaFromSourceCode } from './get-input-schema-from-source-code';
@@ -14,4 +15,6 @@ export { getOutputSchemaFromValue } from './get-output-schema-from-value';
export { getOutputSchemaMismatchIssues } from './get-output-schema-mismatch-issues';
export type { InputJsonSchema } from './input-json-schema.type';
export { inputSchemaToOutputSchema } from './input-schema-to-output-schema';
export { isRecordArraySchema } from './is-record-array-schema';
export { isRecordObjectSchema } from './is-record-object-schema';
export { jsonSchemaToInputSchema } from './json-schema-to-input-schema';
@@ -6,7 +6,9 @@ export type InputJsonSchema = {
| 'object'
| 'array'
| 'integer'
| 'null';
| 'null'
| 'record'
| 'records';
description?: string;
enum?: unknown[];
items?: InputJsonSchema;
@@ -17,4 +19,5 @@ export type InputJsonSchema = {
maximum?: number;
multiline?: boolean;
label?: string;
objectUniversalIdentifier?: string;
};
@@ -28,6 +28,14 @@ const convertProperty = (
): Leaf | Node => {
const label = property.label ?? key;
if (property.type === 'record') {
return { isLeaf: true, type: 'string', label, value: null };
}
if (property.type === 'records') {
return { isLeaf: true, type: 'array', label, value: null };
}
if (property.type === 'object') {
return {
isLeaf: false,
@@ -0,0 +1,19 @@
import { isNonEmptyString } from '@sniptt/guards';
import { isRecordObjectSchema } from '@/logic-function/is-record-object-schema';
type RecordArraySchema = {
type?: string;
objectUniversalIdentifier?: string;
items?: {
type?: string;
objectUniversalIdentifier?: string;
} | null;
};
export const isRecordArraySchema = (
schema: RecordArraySchema | null | undefined,
): boolean =>
(schema?.type === 'records' &&
isNonEmptyString(schema.objectUniversalIdentifier)) ||
(schema?.type === 'array' && isRecordObjectSchema(schema?.items));
@@ -0,0 +1,12 @@
import { isNonEmptyString } from '@sniptt/guards';
type RecordObjectSchema = {
type?: string;
objectUniversalIdentifier?: string;
};
export const isRecordObjectSchema = <TSchema extends RecordObjectSchema>(
schema: TSchema | null | undefined,
): schema is TSchema & { objectUniversalIdentifier: string } =>
(schema?.type === 'record' || schema?.type === 'object') &&
isNonEmptyString(schema.objectUniversalIdentifier);
@@ -36,6 +36,12 @@ const convertProperty = (jsonSchema: InputJsonSchema): InputSchemaProperty => {
);
}
break;
case 'record':
property.type = 'record';
break;
case 'records':
property.type = 'records';
break;
case 'null':
default:
property.type = 'unknown';
@@ -55,6 +61,10 @@ const convertProperty = (jsonSchema: InputJsonSchema): InputSchemaProperty => {
property.label = jsonSchema.label;
}
if (isNonEmptyString(jsonSchema.objectUniversalIdentifier)) {
property.objectUniversalIdentifier = jsonSchema.objectUniversalIdentifier;
}
return property;
};
@@ -94,6 +94,7 @@ export { workflowTriggerSchema } from './schemas/workflow-trigger-schema';
export type { EmailRecipients } from './types/EmailRecipients';
export type { FunctionInput } from './types/FunctionInput';
export type {
RecordSchemaType,
InputSchemaPropertyType,
InputSchemaProperty,
InputSchema,
@@ -1,7 +1,13 @@
import { type FieldMetadataType } from '@/types';
import { type LeafType, type NodeType } from '@/workflow';
export type InputSchemaPropertyType = LeafType | NodeType | FieldMetadataType;
export type RecordSchemaType = 'record' | 'records';
export type InputSchemaPropertyType =
| LeafType
| NodeType
| RecordSchemaType
| FieldMetadataType;
export type InputSchemaProperty = {
type: InputSchemaPropertyType;
@@ -10,6 +16,7 @@ export type InputSchemaProperty = {
properties?: Properties;
multiline?: boolean;
label?: string;
objectUniversalIdentifier?: string;
};
type Properties = {
@@ -56,4 +56,56 @@ describe('getDefaultFunctionInputFromInputSchema', () => {
{ briefs: [] },
]);
});
it('should init record-typed inputs with null and record arrays with empty arrays', () => {
const inputSchema = [
{
type: 'object',
properties: {
company: {
type: 'object',
objectUniversalIdentifier: 'company-universal-identifier',
},
people: {
type: 'array',
items: {
type: 'object',
objectUniversalIdentifier: 'person-universal-identifier',
},
},
plainObject: { type: 'object' },
},
},
] as InputSchema;
expect(getFunctionInputFromInputSchema(inputSchema)).toEqual([
{
company: null,
people: [],
plainObject: {},
},
]);
});
it('should init record/records types with null and empty array', () => {
const inputSchema = [
{
type: 'object',
properties: {
company: {
type: 'record',
objectUniversalIdentifier: 'company-universal-identifier',
},
people: {
type: 'records',
objectUniversalIdentifier: 'person-universal-identifier',
},
},
},
] as InputSchema;
expect(getFunctionInputFromInputSchema(inputSchema)).toEqual([
{ company: null, people: [] },
]);
});
});
@@ -1,11 +1,20 @@
import { type InputSchema, type FunctionInput } from '@/workflow';
import { type InputJsonSchema } from '@/logic-function';
import { isRecordObjectSchema } from '@/logic-function/is-record-object-schema';
import { isDefined } from '@/utils';
export const getFunctionInputFromInputSchema = (
inputSchema: InputSchema | InputJsonSchema[],
): FunctionInput => {
return inputSchema.map((param) => {
if (isRecordObjectSchema(param)) {
return null;
}
if (param.type === 'records') {
return [];
}
if (
isDefined(param.type) &&
['string', 'number', 'boolean'].includes(param.type)