Variables not coming from a Record step should be available in Record Picker (#12708)

We want code and webhook variables available in Record Picker since
those can contains uuid.

This PR:
- update `WorkflowVariablesDropdownObjectItems.tsx` so it manages fields
properly
- factorise both dropdown into a commun hook
- update filterOutputSchema.ts so it does not filter fields that are not
FieldMetadata types
- set relation fields as record object in variable schema so those can
be selected as full record

Before


https://github.com/user-attachments/assets/f4f85402-c056-4fd8-8474-d86bef9d4bc3

After


https://github.com/user-attachments/assets/c6589e18-7dfa-4fc8-a525-3a580e265896
This commit is contained in:
Thomas Trompette
2025-06-19 11:33:21 +02:00
committed by GitHub
parent c16b625752
commit 07cf1ed71d
16 changed files with 229 additions and 159 deletions

View File

@ -2,32 +2,19 @@ import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenu
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import {
BaseOutputSchema,
LinkOutputSchema,
StepOutputSchema,
} from '@/workflow/workflow-variables/types/StepOutputSchema';
import { isBaseOutputSchema } from '@/workflow/workflow-variables/utils/isBaseOutputSchema';
import { isRecordOutputSchema } from '@/workflow/workflow-variables/utils/isRecordOutputSchema';
import { StepOutputSchema } from '@/workflow/workflow-variables/types/StepOutputSchema';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { workflowDiagramTriggerNodeSelectionComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramTriggerNodeSelectionComponentState';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { getCurrentSubStepFromPath } from '@/workflow/workflow-variables/utils/getCurrentSubStepFromPath';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { getStepHeaderLabel } from '@/workflow/workflow-variables/utils/getStepHeaderLabel';
import { isLinkOutputSchema } from '@/workflow/workflow-variables/utils/isLinkOutputSchema';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import {
IconChevronLeft,
OverflowingTextWithTooltip,
useIcons,
} from 'twenty-ui/display';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useVariableDropdown } from '../hooks/useVariableDropdown';
type WorkflowVariablesDropdownFieldItemsProps = {
step: StepOutputSchema;
@ -40,83 +27,19 @@ export const WorkflowVariablesDropdownFieldItems = ({
onSelect,
onBack,
}: WorkflowVariablesDropdownFieldItemsProps) => {
const [currentPath, setCurrentPath] = useState<string[]>([]);
const [searchInputValue, setSearchInputValue] = useState('');
const { getIcon } = useIcons();
const setWorkflowSelectedNode = useSetRecoilComponentStateV2(
workflowSelectedNodeComponentState,
);
const setActiveTabId = useSetRecoilComponentStateV2(
activeTabIdComponentState,
'workflow-serverless-function-tab-list-component-id',
);
const setWorkflowDiagramTriggerNodeSelection = useSetRecoilComponentStateV2(
workflowDiagramTriggerNodeSelectionComponentState,
);
const getDisplayedSubStepFields = () => {
const currentSubStep = getCurrentSubStepFromPath(step, currentPath);
if (isLinkOutputSchema(currentSubStep)) {
return { link: currentSubStep.link };
} else if (isRecordOutputSchema(currentSubStep)) {
return currentSubStep.fields;
} else if (isBaseOutputSchema(currentSubStep)) {
return currentSubStep;
}
};
const handleSelectField = (key: string) => {
const currentSubStep = getCurrentSubStepFromPath(step, currentPath);
const handleSelectBaseOutputSchema = (
baseOutputSchema: BaseOutputSchema,
) => {
if (!baseOutputSchema[key]?.isLeaf) {
setCurrentPath([...currentPath, key]);
setSearchInputValue('');
} else {
onSelect(`{{${step.id}.${[...currentPath, key].join('.')}}}`);
}
};
const handleSelectLinkOutputSchema = (
linkOutputSchema: LinkOutputSchema,
) => {
setWorkflowSelectedNode(step.id);
setWorkflowDiagramTriggerNodeSelection(step.id);
if (isDefined(linkOutputSchema.link.tab)) {
setActiveTabId(linkOutputSchema.link.tab);
}
};
if (isLinkOutputSchema(currentSubStep)) {
handleSelectLinkOutputSchema(currentSubStep);
} else if (isRecordOutputSchema(currentSubStep)) {
handleSelectBaseOutputSchema(currentSubStep.fields);
} else if (isBaseOutputSchema(currentSubStep)) {
handleSelectBaseOutputSchema(currentSubStep);
}
};
const goBack = () => {
if (currentPath.length === 0) {
onBack();
} else {
setCurrentPath(currentPath.slice(0, -1));
}
};
const displayedObject = getDisplayedSubStepFields();
const options = displayedObject ? Object.entries(displayedObject) : [];
const filteredOptions = searchInputValue
? options.filter(
([_, value]) =>
value.label &&
value.label.toLowerCase().includes(searchInputValue.toLowerCase()),
)
: options;
const {
searchInputValue,
setSearchInputValue,
handleSelectField,
goBack,
filteredOptions,
currentPath,
} = useVariableDropdown({
step,
onSelect,
onBack,
});
return (
<DropdownContent widthInPixels={GenericDropdownContentWidth.ExtraLarge}>

View File

@ -5,19 +5,19 @@ import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownM
import { StepOutputSchema } from '@/workflow/workflow-variables/types/StepOutputSchema';
import { getCurrentSubStepFromPath } from '@/workflow/workflow-variables/utils/getCurrentSubStepFromPath';
import { getStepHeaderLabel } from '@/workflow/workflow-variables/utils/getStepHeaderLabel';
import { isBaseOutputSchema } from '@/workflow/workflow-variables/utils/isBaseOutputSchema';
import { isRecordOutputSchema } from '@/workflow/workflow-variables/utils/isRecordOutputSchema';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { useState } from 'react';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { t } from '@lingui/core/macro';
import {
IconChevronLeft,
OverflowingTextWithTooltip,
useIcons,
} from 'twenty-ui/display';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useVariableDropdown } from '../hooks/useVariableDropdown';
type WorkflowVariablesDropdownObjectItemsProps = {
step: StepOutputSchema;
@ -30,19 +30,19 @@ export const WorkflowVariablesDropdownObjectItems = ({
onSelect,
onBack,
}: WorkflowVariablesDropdownObjectItemsProps) => {
const [currentPath, setCurrentPath] = useState<string[]>([]);
const [searchInputValue, setSearchInputValue] = useState('');
const { getIcon } = useIcons();
const getDisplayedSubStepFields = () => {
const currentSubStep = getCurrentSubStepFromPath(step, currentPath);
if (isRecordOutputSchema(currentSubStep)) {
return currentSubStep.fields;
} else if (isBaseOutputSchema(currentSubStep)) {
return currentSubStep;
}
};
const {
currentPath,
filteredOptions,
searchInputValue,
setSearchInputValue,
handleSelectField,
goBack,
} = useVariableDropdown({
step,
onSelect,
onBack,
});
const getDisplayedSubStepObject = () => {
const currentSubStep = getCurrentSubStepFromPath(step, currentPath);
@ -66,19 +66,6 @@ export const WorkflowVariablesDropdownObjectItems = ({
);
};
const handleSelectField = (key: string) => {
setCurrentPath([...currentPath, key]);
setSearchInputValue('');
};
const goBack = () => {
if (currentPath.length === 0) {
onBack();
} else {
setCurrentPath(currentPath.slice(0, -1));
}
};
const displayedSubStepObject = getDisplayedSubStepObject();
const shouldDisplaySubStepObject = searchInputValue
@ -88,16 +75,9 @@ export const WorkflowVariablesDropdownObjectItems = ({
.includes(searchInputValue.toLowerCase())
: true;
const displayedFields = getDisplayedSubStepFields();
const options = displayedFields ? Object.entries(displayedFields) : [];
const filteredOptions = searchInputValue
? options.filter(
([_, value]) =>
value.label &&
value.label.toLowerCase().includes(searchInputValue.toLowerCase()),
)
: options;
const shouldDisplayObject =
shouldDisplaySubStepObject && displayedSubStepObject?.label;
const nameSingular = displayedSubStepObject?.nameSingular;
return (
<DropdownContent widthInPixels={GenericDropdownContentWidth.ExtraLarge}>
@ -120,29 +100,36 @@ export const WorkflowVariablesDropdownObjectItems = ({
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{shouldDisplaySubStepObject && displayedSubStepObject?.label && (
{shouldDisplayObject && (
<MenuItemSelect
selected={false}
focused={false}
onClick={handleSelectObject}
text={displayedSubStepObject.label}
text={displayedSubStepObject?.label || ''}
hasSubMenu={false}
LeftIcon={
displayedSubStepObject.icon
? getIcon(displayedSubStepObject.icon)
: undefined
}
contextualText={t`Pick a ${nameSingular} record`}
/>
)}
{filteredOptions.map(([key, value]) => (
{filteredOptions.length > 0 && shouldDisplayObject && (
<DropdownMenuSeparator />
)}
{filteredOptions.map(([key, option]) => (
<MenuItemSelect
key={key}
selected={false}
focused={false}
onClick={() => handleSelectField(key)}
text={value.label || key}
hasSubMenu={!value.isLeaf}
LeftIcon={value.icon ? getIcon(value.icon) : undefined}
text={option.label || key}
hasSubMenu={!option.isLeaf}
LeftIcon={option.icon ? getIcon(option.icon) : undefined}
contextualText={
option.isLeaf ? option?.value?.toString() : undefined
}
/>
))}
</DropdownMenuItemsContainer>

View File

@ -0,0 +1,123 @@
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { workflowDiagramTriggerNodeSelectionComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramTriggerNodeSelectionComponentState';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import {
BaseOutputSchema,
LinkOutputSchema,
StepOutputSchema,
} from '../types/StepOutputSchema';
import { getCurrentSubStepFromPath } from '../utils/getCurrentSubStepFromPath';
import { isBaseOutputSchema } from '../utils/isBaseOutputSchema';
import { isLinkOutputSchema } from '../utils/isLinkOutputSchema';
import { isRecordOutputSchema } from '../utils/isRecordOutputSchema';
type UseVariableDropdownProps = {
step: StepOutputSchema;
onSelect: (value: string) => void;
onBack: () => void;
};
type UseVariableDropdownReturn = {
currentPath: string[];
searchInputValue: string;
setSearchInputValue: (value: string) => void;
handleSelectField: (key: string) => void;
goBack: () => void;
filteredOptions: [string, any][];
};
export const useVariableDropdown = ({
step,
onSelect,
onBack,
}: UseVariableDropdownProps): UseVariableDropdownReturn => {
const [currentPath, setCurrentPath] = useState<string[]>([]);
const [searchInputValue, setSearchInputValue] = useState('');
const setWorkflowSelectedNode = useSetRecoilComponentStateV2(
workflowSelectedNodeComponentState,
);
const setActiveTabId = useSetRecoilComponentStateV2(
activeTabIdComponentState,
'workflow-serverless-function-tab-list-component-id',
);
const setWorkflowDiagramTriggerNodeSelection = useSetRecoilComponentStateV2(
workflowDiagramTriggerNodeSelectionComponentState,
);
const getDisplayedSubStepFields = () => {
const currentSubStep = getCurrentSubStepFromPath(step, currentPath);
if (isLinkOutputSchema(currentSubStep)) {
return { link: currentSubStep.link };
} else if (isRecordOutputSchema(currentSubStep)) {
return currentSubStep.fields;
} else if (isBaseOutputSchema(currentSubStep)) {
return currentSubStep;
}
};
const handleSelectField = (key: string) => {
const currentSubStep = getCurrentSubStepFromPath(step, currentPath);
const handleSelectBaseOutputSchema = (
baseOutputSchema: BaseOutputSchema,
) => {
if (!baseOutputSchema[key]?.isLeaf) {
setCurrentPath([...currentPath, key]);
setSearchInputValue('');
} else {
onSelect(`{{${step.id}.${[...currentPath, key].join('.')}}}`);
}
};
const handleSelectLinkOutputSchema = (
linkOutputSchema: LinkOutputSchema,
) => {
setWorkflowSelectedNode(step.id);
setWorkflowDiagramTriggerNodeSelection(step.id);
if (isDefined(linkOutputSchema.link.tab)) {
setActiveTabId(linkOutputSchema.link.tab);
}
};
if (isLinkOutputSchema(currentSubStep)) {
handleSelectLinkOutputSchema(currentSubStep);
} else if (isRecordOutputSchema(currentSubStep)) {
handleSelectBaseOutputSchema(currentSubStep.fields);
} else if (isBaseOutputSchema(currentSubStep)) {
handleSelectBaseOutputSchema(currentSubStep);
}
};
const goBack = () => {
if (currentPath.length === 0) {
onBack();
} else {
setCurrentPath(currentPath.slice(0, -1));
}
};
const displayedFields = getDisplayedSubStepFields();
const options = displayedFields ? Object.entries(displayedFields) : [];
const filteredOptions = searchInputValue
? options.filter(
([_, value]) =>
value.label &&
value.label.toLowerCase().includes(searchInputValue.toLowerCase()),
)
: options;
return {
currentPath,
searchInputValue,
setSearchInputValue,
handleSelectField,
goBack,
filteredOptions,
};
};

View File

@ -10,6 +10,7 @@ type Leaf = {
type Node = {
isLeaf: false;
type?: InputSchemaPropertyType;
icon?: string;
label?: string;
value: OutputSchema;

View File

@ -1,4 +1,5 @@
import { OutputSchema } from '@/workflow/workflow-variables/types/StepOutputSchema';
import { FieldMetadataType } from 'twenty-shared/types';
import { filterOutputSchema } from '../filterOutputSchema';
describe('filterOutputSchema', () => {
@ -85,9 +86,10 @@ describe('filterOutputSchema', () => {
expect(filterOutputSchema(inputSchema, 'person')).toEqual(expectedSchema);
});
it('should ignore leaf fields', () => {
it('should ignore leaf fields that are field metadata types', () => {
const inputSchema = createRecordSchema('company', {
name: { isLeaf: true, value: 'string' },
id: { isLeaf: true, type: FieldMetadataType.UUID },
employee: {
isLeaf: false,
value: createRecordSchema('person'),
@ -97,6 +99,7 @@ describe('filterOutputSchema', () => {
const expectedSchema = {
_outputSchemaType: 'RECORD',
fields: {
name: { isLeaf: true, value: 'string' },
employee: {
isLeaf: false,
value: createRecordSchema('person'),
@ -117,6 +120,7 @@ describe('filterOutputSchema', () => {
const inputSchema = createBaseSchema({
field1: {
isLeaf: true,
type: FieldMetadataType.TEXT,
value: 'string',
},
});

View File

@ -4,6 +4,7 @@ import {
RecordOutputSchema,
} from '@/workflow/workflow-variables/types/StepOutputSchema';
import { isBaseOutputSchema } from '@/workflow/workflow-variables/utils/isBaseOutputSchema';
import { isFieldTypeCompatibleWithRecordId } from '@/workflow/workflow-variables/utils/isFieldTypeCompatibleWithRecordId';
import { isLinkOutputSchema } from '@/workflow/workflow-variables/utils/isLinkOutputSchema';
import { isRecordOutputSchema } from '@/workflow/workflow-variables/utils/isRecordOutputSchema';
import { isDefined } from 'twenty-shared/utils';
@ -33,6 +34,10 @@ const filterRecordOutputSchema = (
const field = outputSchema.fields[key];
if (field.isLeaf) {
if (isFieldTypeCompatibleWithRecordId(field.type)) {
filteredFields[key] = field;
hasValidFields = true;
}
continue;
}
@ -75,6 +80,10 @@ const filterBaseOutputSchema = (
const field = outputSchema[key];
if (field.isLeaf) {
if (isFieldTypeCompatibleWithRecordId(field.type)) {
filteredSchema[key] = field;
hasValidFields = true;
}
continue;
}

View File

@ -0,0 +1,7 @@
import { InputSchemaPropertyType } from '@/workflow/types/InputSchema';
export const isFieldTypeCompatibleWithRecordId = (
type?: InputSchemaPropertyType,
): boolean => {
return !type || type === 'string' || type === 'unknown';
};

View File

@ -94,12 +94,13 @@ const searchCurrentStepOutputSchema = ({
}
return {
variableLabel: isFullRecord
? getDisplayedSubStepObjectLabel(currentSubStep)
: getDisplayedSubStepFieldLabel(
isSelectedFieldInNextKey ? nextKey : selectedField,
currentSubStep,
),
variableLabel:
isFullRecord && isRecordOutputSchema(currentSubStep)
? getDisplayedSubStepObjectLabel(currentSubStep)
: getDisplayedSubStepFieldLabel(
isSelectedFieldInNextKey ? nextKey : selectedField,
currentSubStep,
),
variablePathLabel,
};
};