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
@@ -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: [] };
};