Add Multiselect for forms (#9092)

- Add new FormMultiSelectField component
- Factorize existing display / input into new ui components
- Update the variable resolver to handle arrays properly

<img width="526" alt="Capture d’écran 2024-12-17 à 11 46 38"
src="https://github.com/user-attachments/assets/6d37b513-8caa-43d0-a27e-ab55dac21f6d"
/>
This commit is contained in:
Thomas Trompette
2024-12-17 14:41:55 +01:00
committed by GitHub
parent c754585e47
commit f0de1ab245
13 changed files with 504 additions and 181 deletions

View File

@ -1,9 +1,10 @@
import { FormAddressFieldInput } from '@/object-record/record-field/form-types/components/FormAddressFieldInput';
import { FormBooleanFieldInput } from '@/object-record/record-field/form-types/components/FormBooleanFieldInput';
import { FormDateFieldInput } from '@/object-record/record-field/form-types/components/FormDateFieldInput';
import { FormEmailsFieldInput } from '@/object-record/record-field/form-types/components/FormEmailsFieldInput';
import { FormFullNameFieldInput } from '@/object-record/record-field/form-types/components/FormFullNameFieldInput';
import { FormLinksFieldInput } from '@/object-record/record-field/form-types/components/FormLinksFieldInput';
import { FormDateFieldInput } from '@/object-record/record-field/form-types/components/FormDateFieldInput';
import { FormMultiSelectFieldInput } from '@/object-record/record-field/form-types/components/FormMultiSelectFieldInput';
import { FormNumberFieldInput } from '@/object-record/record-field/form-types/components/FormNumberFieldInput';
import { FormSelectFieldInput } from '@/object-record/record-field/form-types/components/FormSelectFieldInput';
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
@ -15,13 +16,15 @@ import {
FieldFullNameValue,
FieldLinksValue,
FieldMetadata,
FieldMultiSelectValue,
} from '@/object-record/record-field/types/FieldMetadata';
import { isFieldAddress } from '@/object-record/record-field/types/guards/isFieldAddress';
import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean';
import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate';
import { isFieldEmails } from '@/object-record/record-field/types/guards/isFieldEmails';
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate';
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber';
import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText';
@ -107,5 +110,13 @@ export const FormFieldInput = ({
onPersist={onPersist}
VariablePicker={VariablePicker}
/>
) : isFieldMultiSelect(field) ? (
<FormMultiSelectFieldInput
label={field.label}
defaultValue={defaultValue as FieldMultiSelectValue | string | undefined}
onPersist={onPersist}
VariablePicker={VariablePicker}
options={field.metadata.options}
/>
) : null;
};

View File

@ -0,0 +1,211 @@
import styled from '@emotion/styled';
import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
import { FormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputInputContainer';
import { FormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/FormFieldInputRowContainer';
import { VariableChip } from '@/object-record/record-field/form-types/components/VariableChip';
import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
import { FieldMultiSelectValue } from '@/object-record/record-field/types/FieldMetadata';
import { MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID } from '@/object-record/relation-picker/constants/MultiObjectRecordSelectSelectableListId';
import { SelectOption } from '@/spreadsheet-import/types';
import { MultiSelectDisplay } from '@/ui/field/display/components/MultiSelectDisplay';
import { MultiSelectInput } from '@/ui/field/input/components/MultiSelectInput';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import { useId, useState } from 'react';
import { VisibilityHidden } from 'twenty-ui';
import { isDefined } from '~/utils/isDefined';
type FormMultiSelectFieldInputProps = {
label?: string;
defaultValue: FieldMultiSelectValue | string | undefined;
onPersist: (value: FieldMultiSelectValue | string) => void;
VariablePicker?: VariablePickerComponent;
options: SelectOption[];
};
const StyledDisplayModeContainer = styled.button`
width: 100%;
align-items: center;
display: flex;
cursor: pointer;
border: none;
background: transparent;
font-family: inherit;
padding-inline: ${({ theme }) => theme.spacing(2)};
&:hover,
&[data-open='true'] {
background-color: ${({ theme }) => theme.background.transparent.lighter};
}
`;
const StyledSelectInputContainer = styled.div`
position: absolute;
z-index: 1;
top: ${({ theme }) => theme.spacing(8)};
`;
export const FormMultiSelectFieldInput = ({
label,
defaultValue,
onPersist,
VariablePicker,
options,
}: FormMultiSelectFieldInputProps) => {
const inputId = useId();
const hotkeyScope = MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID;
const {
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
} = usePreviousHotkeyScope();
const [draftValue, setDraftValue] = useState<
| {
type: 'static';
value: FieldMultiSelectValue;
editingMode: 'view' | 'edit';
}
| {
type: 'variable';
value: string;
}
>(
isStandaloneVariableString(defaultValue)
? {
type: 'variable',
value: defaultValue,
}
: {
type: 'static',
value: isDefined(defaultValue) ? defaultValue : [],
editingMode: 'view',
},
);
const handleDisplayModeClick = () => {
if (draftValue.type !== 'static') {
throw new Error(
'This function can only be called when editing a static value.',
);
}
setDraftValue({
...draftValue,
editingMode: 'edit',
});
setHotkeyScopeAndMemorizePreviousScope(hotkeyScope);
};
const onOptionSelected = (value: FieldMultiSelectValue) => {
if (draftValue.type !== 'static') {
throw new Error('Can only be called when editing a static value');
}
setDraftValue({
type: 'static',
value,
editingMode: 'edit',
});
onPersist(value);
};
const onCancel = () => {
if (draftValue.type !== 'static') {
throw new Error('Can only be called when editing a static value');
}
setDraftValue({
...draftValue,
editingMode: 'view',
});
goBackToPreviousHotkeyScope();
};
const handleVariableTagInsert = (variableName: string) => {
setDraftValue({
type: 'variable',
value: variableName,
});
onPersist(variableName);
};
const handleUnlinkVariable = () => {
setDraftValue({
type: 'static',
value: [],
editingMode: 'view',
});
onPersist([]);
};
const selectedNames =
draftValue.type === 'static' ? draftValue.value : undefined;
const selectedOptions =
isDefined(selectedNames) && isDefined(options)
? options.filter((option) =>
selectedNames.some((name) => option.value === name),
)
: undefined;
return (
<FormFieldInputContainer>
{label ? <InputLabel>{label}</InputLabel> : null}
<FormFieldInputRowContainer>
<FormFieldInputInputContainer
hasRightElement={isDefined(VariablePicker)}
>
{draftValue.type === 'static' ? (
<StyledDisplayModeContainer
data-open={draftValue.editingMode === 'edit'}
onClick={handleDisplayModeClick}
>
<VisibilityHidden>Edit</VisibilityHidden>
{isDefined(selectedOptions) ? (
<MultiSelectDisplay
values={selectedNames}
options={selectedOptions}
/>
) : null}
</StyledDisplayModeContainer>
) : (
<VariableChip
rawVariableName={draftValue.value}
onRemove={handleUnlinkVariable}
/>
)}
</FormFieldInputInputContainer>
<StyledSelectInputContainer>
{draftValue.type === 'static' &&
draftValue.editingMode === 'edit' && (
<MultiSelectInput
hotkeyScope={hotkeyScope}
options={options}
onCancel={onCancel}
onOptionSelected={onOptionSelected}
values={draftValue.value}
/>
)}
</StyledSelectInputContainer>
{VariablePicker && (
<VariablePicker
inputId={inputId}
onVariableSelect={handleVariableTagInsert}
/>
)}
</FormFieldInputRowContainer>
</FormFieldInputContainer>
);
};

View File

@ -0,0 +1,50 @@
import { Meta, StoryObj } from '@storybook/react';
import { within } from '@storybook/test';
import { FormMultiSelectFieldInput } from '../FormMultiSelectFieldInput';
const meta: Meta<typeof FormMultiSelectFieldInput> = {
title: 'UI/Data/Field/Form/Input/FormMultiSelectFieldInput',
component: FormMultiSelectFieldInput,
args: {},
argTypes: {},
};
export default meta;
type Story = StoryObj<typeof FormMultiSelectFieldInput>;
export const Default: Story = {
args: {
label: 'Work Policy',
defaultValue: ['WORK_POLICY_1', 'WORK_POLICY_2'],
options: [
{
label: 'Work Policy 1',
value: 'WORK_POLICY_1',
color: 'blue',
},
{
label: 'Work Policy 2',
value: 'WORK_POLICY_2',
color: 'green',
},
{
label: 'Work Policy 3',
value: 'WORK_POLICY_3',
color: 'red',
},
{
label: 'Work Policy 4',
value: 'WORK_POLICY_4',
color: 'yellow',
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText('Work Policy');
await canvas.findByText('Work Policy 1');
await canvas.findByText('Work Policy 2');
},
};

View File

@ -1,25 +1,10 @@
import { styled } from '@linaria/react';
import { Tag, THEME_COMMON } from 'twenty-ui';
import { Tag } from 'twenty-ui';
import { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus';
import { useMultiSelectFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useMultiSelectFieldDisplay';
import { MultiSelectDisplay } from '@/ui/field/display/components/MultiSelectDisplay';
import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList';
const spacing1 = THEME_COMMON.spacing(1);
const StyledContainer = styled.div`
align-items: center;
display: flex;
gap: ${spacing1};
justify-content: flex-start;
max-width: 100%;
overflow: hidden;
width: 100%;
`;
export const MultiSelectFieldDisplay = () => {
const { fieldValue, fieldDefinition } = useMultiSelectFieldDisplay();
@ -44,15 +29,9 @@ export const MultiSelectFieldDisplay = () => {
))}
</ExpandableList>
) : (
<StyledContainer>
{selectedOptions.map((selectedOption, index) => (
<Tag
preventShrink
key={index}
color={selectedOption.color}
text={selectedOption.label}
/>
))}
</StyledContainer>
<MultiSelectDisplay
values={fieldValue}
options={fieldDefinition.metadata.options}
/>
);
};

View File

@ -1,28 +1,5 @@
import styled from '@emotion/styled';
import { useRef, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
import { useMultiSelectField } from '@/object-record/record-field/meta-types/hooks/useMultiSelectField';
import { MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID } from '@/object-record/relation-picker/constants/MultiObjectRecordSelectSelectableListId';
import { DropdownMenu } 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 { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { useSelectableListStates } from '@/ui/layout/selectable-list/hooks/internal/useSelectableListStates';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { MenuItemMultiSelectTag } from 'twenty-ui';
import { isDefined } from '~/utils/isDefined';
import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly';
const StyledRelationPickerContainer = styled.div`
left: -1px;
position: absolute;
top: -1px;
`;
import { MultiSelectInput } from '@/ui/field/input/components/MultiSelectInput';
type MultiSelectFieldInputProps = {
onCancel?: () => void;
@ -31,112 +8,16 @@ type MultiSelectFieldInputProps = {
export const MultiSelectFieldInput = ({
onCancel,
}: MultiSelectFieldInputProps) => {
const { selectedItemIdState } = useSelectableListStates({
selectableListScopeId: MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID,
});
const { resetSelectedItem } = useSelectableList(
MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID,
);
const { persistField, fieldDefinition, fieldValues, hotkeyScope } =
useMultiSelectField();
const selectedItemId = useRecoilValue(selectedItemIdState);
const [searchFilter, setSearchFilter] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
const selectedOptions = fieldDefinition.metadata.options.filter((option) =>
fieldValues?.includes(option.value),
);
const filteredOptionsInDropDown = fieldDefinition.metadata.options.filter(
(option) => option.label.toLowerCase().includes(searchFilter.toLowerCase()),
);
const formatNewSelectedOptions = (value: string) => {
const selectedOptionsValues = selectedOptions.map(
(selectedOption) => selectedOption.value,
);
if (!selectedOptionsValues.includes(value)) {
return [value, ...selectedOptionsValues];
} else {
return selectedOptionsValues.filter(
(selectedOptionsValue) => selectedOptionsValue !== value,
);
}
};
useScopedHotkeys(
Key.Escape,
() => {
onCancel?.();
resetSelectedItem();
},
hotkeyScope,
[onCancel, resetSelectedItem],
);
useListenClickOutside({
refs: [containerRef],
callback: (event) => {
event.stopImmediatePropagation();
const weAreNotInAnHTMLInput = !(
event.target instanceof HTMLInputElement &&
event.target.tagName === 'INPUT'
);
if (weAreNotInAnHTMLInput && isDefined(onCancel)) {
onCancel();
}
resetSelectedItem();
},
listenerId: 'MultiSelectFieldInput',
});
const optionIds = filteredOptionsInDropDown.map((option) => option.value);
return (
<SelectableList
selectableListId={MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID}
selectableItemIdArray={optionIds}
<MultiSelectInput
hotkeyScope={hotkeyScope}
onEnter={(itemId) => {
const option = filteredOptionsInDropDown.find(
(option) => option.value === itemId,
);
if (isDefined(option)) {
persistField(formatNewSelectedOptions(option.value));
}
}}
>
<StyledRelationPickerContainer ref={containerRef}>
<DropdownMenu data-select-disable>
<DropdownMenuSearchInput
value={searchFilter}
onChange={(event) =>
setSearchFilter(
turnIntoEmptyStringIfWhitespacesOnly(event.currentTarget.value),
)
}
autoFocus
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{filteredOptionsInDropDown.map((option) => {
return (
<MenuItemMultiSelectTag
key={option.value}
selected={fieldValues?.includes(option.value) || false}
text={option.label}
color={option.color}
onClick={() =>
persistField(formatNewSelectedOptions(option.value))
}
isKeySelected={selectedItemId === option.value}
/>
);
})}
</DropdownMenuItemsContainer>
</DropdownMenu>
</StyledRelationPickerContainer>
</SelectableList>
options={fieldDefinition.metadata.options}
onCancel={onCancel}
onOptionSelected={persistField}
values={fieldValues}
/>
);
};

View File

@ -0,0 +1,47 @@
import { Tag, THEME_COMMON } from 'twenty-ui';
import { FieldMultiSelectValue } from '@/object-record/record-field/types/FieldMetadata';
import { SelectOption } from '@/spreadsheet-import/types';
import styled from '@emotion/styled';
const spacing1 = THEME_COMMON.spacing(1);
const StyledContainer = styled.div`
align-items: center;
display: flex;
gap: ${spacing1};
justify-content: flex-start;
max-width: 100%;
overflow: hidden;
width: 100%;
`;
export const MultiSelectDisplay = ({
values,
options,
}: {
values: FieldMultiSelectValue | undefined;
options: SelectOption[];
}) => {
const selectedOptions = values
? options?.filter((option) => values.includes(option.value))
: [];
if (!selectedOptions) return null;
return (
<StyledContainer>
{selectedOptions.map((selectedOption, index) => (
<Tag
preventShrink
key={index}
color={selectedOption.color ?? 'transparent'}
text={selectedOption.label}
/>
))}
</StyledContainer>
);
};

View File

@ -0,0 +1,150 @@
import styled from '@emotion/styled';
import { useRef, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
import { FieldMultiSelectValue } from '@/object-record/record-field/types/FieldMetadata';
import { MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID } from '@/object-record/relation-picker/constants/MultiObjectRecordSelectSelectableListId';
import { SelectOption } from '@/spreadsheet-import/types';
import { DropdownMenu } 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 { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { useSelectableListStates } from '@/ui/layout/selectable-list/hooks/internal/useSelectableListStates';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { MenuItemMultiSelectTag } from 'twenty-ui';
import { isDefined } from '~/utils/isDefined';
import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly';
const StyledRelationPickerContainer = styled.div`
left: -1px;
position: absolute;
top: -1px;
`;
type MultiSelectInputProps = {
values: FieldMultiSelectValue;
hotkeyScope: string;
onCancel?: () => void;
options: SelectOption[];
onOptionSelected: (value: FieldMultiSelectValue) => void;
};
export const MultiSelectInput = ({
values,
options,
hotkeyScope,
onCancel,
onOptionSelected,
}: MultiSelectInputProps) => {
const { selectedItemIdState } = useSelectableListStates({
selectableListScopeId: MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID,
});
const { resetSelectedItem } = useSelectableList(
MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID,
);
const selectedItemId = useRecoilValue(selectedItemIdState);
const [searchFilter, setSearchFilter] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
const selectedOptions = options.filter((option) =>
values?.includes(option.value),
);
const filteredOptionsInDropDown = options.filter((option) =>
option.label.toLowerCase().includes(searchFilter.toLowerCase()),
);
const formatNewSelectedOptions = (value: string) => {
const selectedOptionsValues = selectedOptions.map(
(selectedOption) => selectedOption.value,
);
if (!selectedOptionsValues.includes(value)) {
return [value, ...selectedOptionsValues];
} else {
return selectedOptionsValues.filter(
(selectedOptionsValue) => selectedOptionsValue !== value,
);
}
};
useScopedHotkeys(
Key.Escape,
() => {
onCancel?.();
resetSelectedItem();
},
hotkeyScope,
[onCancel, resetSelectedItem],
);
useListenClickOutside({
refs: [containerRef],
callback: (event) => {
event.stopImmediatePropagation();
const weAreNotInAnHTMLInput = !(
event.target instanceof HTMLInputElement &&
event.target.tagName === 'INPUT'
);
if (weAreNotInAnHTMLInput && isDefined(onCancel)) {
onCancel();
}
resetSelectedItem();
},
listenerId: 'MultiSelectFieldInput',
});
const optionIds = filteredOptionsInDropDown.map((option) => option.value);
return (
<SelectableList
selectableListId={MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID}
selectableItemIdArray={optionIds}
hotkeyScope={hotkeyScope}
onEnter={(itemId) => {
const option = filteredOptionsInDropDown.find(
(option) => option.value === itemId,
);
if (isDefined(option)) {
onOptionSelected(formatNewSelectedOptions(option.value));
}
}}
>
<StyledRelationPickerContainer ref={containerRef}>
<DropdownMenu data-select-disable>
<DropdownMenuSearchInput
value={searchFilter}
onChange={(event) =>
setSearchFilter(
turnIntoEmptyStringIfWhitespacesOnly(event.currentTarget.value),
)
}
autoFocus
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{filteredOptionsInDropDown.map((option) => {
return (
<MenuItemMultiSelectTag
key={option.value}
selected={values?.includes(option.value) || false}
text={option.label}
color={option.color ?? 'transparent'}
onClick={() =>
onOptionSelected(formatNewSelectedOptions(option.value))
}
isKeySelected={selectedItemId === option.value}
/>
);
})}
</DropdownMenuItemsContainer>
</DropdownMenu>
</StyledRelationPickerContainer>
</SelectableList>
);
};

View File

@ -3,6 +3,7 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadata
import { formatFieldMetadataItemAsFieldDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition';
import { FormFieldInput } from '@/object-record/record-field/components/FormFieldInput';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
import { WorkflowStepHeader } from '@/workflow/components/WorkflowStepHeader';
import { WorkflowVariablePicker } from '@/workflow/components/WorkflowVariablePicker';
import { WorkflowCreateRecordAction } from '@/workflow/types/Workflow';
@ -17,7 +18,6 @@ import {
import { JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce';
import { FieldMetadataType } from '~/generated/graphql';
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
type WorkflowEditActionFormCreateRecordProps = {
action: WorkflowCreateRecordAction;

View File

@ -1,7 +1,7 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowStepHeader } from '@/workflow/components/WorkflowStepHeader';
import { WorkflowSingleRecordPicker } from '@/workflow/components/WorkflowSingleRecordPicker';
import { WorkflowStepHeader } from '@/workflow/components/WorkflowStepHeader';
import { WorkflowUpdateRecordAction } from '@/workflow/types/Workflow';
import { useTheme } from '@emotion/react';
import { useEffect, useState } from 'react';
@ -12,9 +12,9 @@ import {
useIcons,
} from 'twenty-ui';
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
import { JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce';
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
type WorkflowEditActionFormUpdateRecordProps = {
action: WorkflowUpdateRecordAction;

View File

@ -8,5 +8,4 @@ export class WorkflowExecutorException extends CustomException {
export enum WorkflowExecutorExceptionCode {
WORKFLOW_FAILED = 'WORKFLOW_FAILED',
VARIABLE_EVALUATION_FAILED = 'VARIABLE_EVALUATION_FAILED',
}

View File

@ -31,7 +31,7 @@ describe('resolveInput', () => {
});
it('should handle non-existent variables', () => {
expect(resolveInput('{{user.email}}', context)).toBe('');
expect(resolveInput('{{user.email}}', context)).toBe(undefined);
});
it('should resolve variables in an array', () => {
@ -67,15 +67,11 @@ describe('resolveInput', () => {
const expected = {
user: {
displayName: 'John Doe',
preferences: ['dark', 'true'],
preferences: ['dark', true],
},
staticData: [1, 2, 3],
};
expect(resolveInput(input, context)).toEqual(expected);
});
it('should throw an error for invalid expressions', () => {
expect(() => resolveInput('{{invalidFunction()}}', context)).toThrow();
});
});

View File

@ -2,11 +2,6 @@ import { isNil, isString } from '@nestjs/common/utils/shared.utils';
import Handlebars from 'handlebars';
import {
WorkflowExecutorException,
WorkflowExecutorExceptionCode,
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-executor.exception';
const VARIABLE_PATTERN = RegExp('\\{\\{(.*?)\\}\\}', 'g');
export const resolveInput = (
@ -81,18 +76,22 @@ const resolveString = (
});
};
const evalFromContext = (
input: string,
context: Record<string, unknown>,
): string => {
const evalFromContext = (input: string, context: Record<string, unknown>) => {
try {
const inferredInput = Handlebars.compile(input)(context);
Handlebars.registerHelper('json', (input: string) => JSON.stringify(input));
return inferredInput ?? '';
const inputWithHelper = input
.replace('{{', '{{{ json ')
.replace('}}', ' }}}');
const inferredInput = Handlebars.compile(inputWithHelper)(context, {
helpers: {
json: (input: string) => JSON.stringify(input),
},
});
return JSON.parse(inferredInput) ?? '';
} catch (exception) {
throw new WorkflowExecutorException(
`Failed to evaluate variable ${input}: ${exception}`,
WorkflowExecutorExceptionCode.VARIABLE_EVALUATION_FAILED,
);
return undefined;
}
};

View File

@ -11,7 +11,7 @@ type MenuItemMultiSelectTagProps = {
className?: string;
isKeySelected?: boolean;
onClick?: () => void;
color: ThemeColor;
color: ThemeColor | 'transparent';
text: string;
};