From f0de1ab24511e70911e8d00efed85de06c0e1dac Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Tue, 17 Dec 2024 14:41:55 +0100 Subject: [PATCH] Add Multiselect for forms (#9092) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add new FormMultiSelectField component - Factorize existing display / input into new ui components - Update the variable resolver to handle arrays properly Capture d’écran 2024-12-17 à 11 46 38 --- .../components/FormFieldInput.tsx | 15 +- .../components/FormMultiSelectFieldInput.tsx | 211 ++++++++++++++++++ .../FormMultiSelectFieldInput.stories.tsx | 50 +++++ .../components/MultiSelectFieldDisplay.tsx | 33 +-- .../components/MultiSelectFieldInput.tsx | 133 +---------- .../display/components/MultiSelectDisplay.tsx | 47 ++++ .../input/components/MultiSelectInput.tsx | 150 +++++++++++++ .../WorkflowEditActionFormCreateRecord.tsx | 2 +- .../WorkflowEditActionFormUpdateRecord.tsx | 4 +- .../exceptions/workflow-executor.exception.ts | 1 - .../__tests__/variable-resolver.util.spec.ts | 8 +- .../utils/variable-resolver.util.ts | 29 ++- .../components/MenuItemMultiSelectTag.tsx | 2 +- 13 files changed, 504 insertions(+), 181 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormMultiSelectFieldInput.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormMultiSelectFieldInput.stories.tsx create mode 100644 packages/twenty-front/src/modules/ui/field/display/components/MultiSelectDisplay.tsx create mode 100644 packages/twenty-front/src/modules/ui/field/input/components/MultiSelectInput.tsx diff --git a/packages/twenty-front/src/modules/object-record/record-field/components/FormFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/components/FormFieldInput.tsx index 38084a4d2..b6b97e45b 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/components/FormFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/components/FormFieldInput.tsx @@ -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) ? ( + ) : null; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormMultiSelectFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormMultiSelectFieldInput.tsx new file mode 100644 index 000000000..88ff784a2 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormMultiSelectFieldInput.tsx @@ -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 ( + + {label ? {label} : null} + + + + {draftValue.type === 'static' ? ( + + Edit + + {isDefined(selectedOptions) ? ( + + ) : null} + + ) : ( + + )} + + + {draftValue.type === 'static' && + draftValue.editingMode === 'edit' && ( + + )} + + + {VariablePicker && ( + + )} + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormMultiSelectFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormMultiSelectFieldInput.stories.tsx new file mode 100644 index 000000000..2b4df71f5 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormMultiSelectFieldInput.stories.tsx @@ -0,0 +1,50 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { within } from '@storybook/test'; +import { FormMultiSelectFieldInput } from '../FormMultiSelectFieldInput'; + +const meta: Meta = { + title: 'UI/Data/Field/Form/Input/FormMultiSelectFieldInput', + component: FormMultiSelectFieldInput, + args: {}, + argTypes: {}, +}; + +export default meta; + +type Story = StoryObj; + +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'); + }, +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/MultiSelectFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/MultiSelectFieldDisplay.tsx index 7fcc1f4eb..42d258b77 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/MultiSelectFieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/MultiSelectFieldDisplay.tsx @@ -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 = () => { ))} ) : ( - - {selectedOptions.map((selectedOption, index) => ( - - ))} - + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiSelectFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiSelectFieldInput.tsx index b76fecda5..eb8939aa4 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiSelectFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiSelectFieldInput.tsx @@ -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(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 ( - { - const option = filteredOptionsInDropDown.find( - (option) => option.value === itemId, - ); - if (isDefined(option)) { - persistField(formatNewSelectedOptions(option.value)); - } - }} - > - - - - setSearchFilter( - turnIntoEmptyStringIfWhitespacesOnly(event.currentTarget.value), - ) - } - autoFocus - /> - - - {filteredOptionsInDropDown.map((option) => { - return ( - - persistField(formatNewSelectedOptions(option.value)) - } - isKeySelected={selectedItemId === option.value} - /> - ); - })} - - - - + options={fieldDefinition.metadata.options} + onCancel={onCancel} + onOptionSelected={persistField} + values={fieldValues} + /> ); }; diff --git a/packages/twenty-front/src/modules/ui/field/display/components/MultiSelectDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/MultiSelectDisplay.tsx new file mode 100644 index 000000000..a5117db33 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/field/display/components/MultiSelectDisplay.tsx @@ -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 ( + + {selectedOptions.map((selectedOption, index) => ( + + ))} + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/field/input/components/MultiSelectInput.tsx b/packages/twenty-front/src/modules/ui/field/input/components/MultiSelectInput.tsx new file mode 100644 index 000000000..88c100c81 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/field/input/components/MultiSelectInput.tsx @@ -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(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 ( + { + const option = filteredOptionsInDropDown.find( + (option) => option.value === itemId, + ); + if (isDefined(option)) { + onOptionSelected(formatNewSelectedOptions(option.value)); + } + }} + > + + + + setSearchFilter( + turnIntoEmptyStringIfWhitespacesOnly(event.currentTarget.value), + ) + } + autoFocus + /> + + + {filteredOptionsInDropDown.map((option) => { + return ( + + onOptionSelected(formatNewSelectedOptions(option.value)) + } + isKeySelected={selectedItemId === option.value} + /> + ); + })} + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-actions/components/WorkflowEditActionFormCreateRecord.tsx b/packages/twenty-front/src/modules/workflow/workflow-actions/components/WorkflowEditActionFormCreateRecord.tsx index aed2a1670..58a4ee114 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-actions/components/WorkflowEditActionFormCreateRecord.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-actions/components/WorkflowEditActionFormCreateRecord.tsx @@ -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; diff --git a/packages/twenty-front/src/modules/workflow/workflow-actions/components/WorkflowEditActionFormUpdateRecord.tsx b/packages/twenty-front/src/modules/workflow/workflow-actions/components/WorkflowEditActionFormUpdateRecord.tsx index 2e14aded4..4c075885d 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-actions/components/WorkflowEditActionFormUpdateRecord.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-actions/components/WorkflowEditActionFormUpdateRecord.tsx @@ -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; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/exceptions/workflow-executor.exception.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/exceptions/workflow-executor.exception.ts index aee8cc34d..cdba717b8 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/exceptions/workflow-executor.exception.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/exceptions/workflow-executor.exception.ts @@ -8,5 +8,4 @@ export class WorkflowExecutorException extends CustomException { export enum WorkflowExecutorExceptionCode { WORKFLOW_FAILED = 'WORKFLOW_FAILED', - VARIABLE_EVALUATION_FAILED = 'VARIABLE_EVALUATION_FAILED', } diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/variable-resolver.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/variable-resolver.util.spec.ts index 0e73ec4a2..dc41cb81d 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/variable-resolver.util.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/variable-resolver.util.spec.ts @@ -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(); - }); }); diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/variable-resolver.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/variable-resolver.util.ts index c4fc012d4..6c391aa52 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/variable-resolver.util.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/variable-resolver.util.ts @@ -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 => { +const evalFromContext = (input: string, context: Record) => { 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; } }; diff --git a/packages/twenty-ui/src/navigation/menu-item/components/MenuItemMultiSelectTag.tsx b/packages/twenty-ui/src/navigation/menu-item/components/MenuItemMultiSelectTag.tsx index a659ca7ca..7870ccec8 100644 --- a/packages/twenty-ui/src/navigation/menu-item/components/MenuItemMultiSelectTag.tsx +++ b/packages/twenty-ui/src/navigation/menu-item/components/MenuItemMultiSelectTag.tsx @@ -11,7 +11,7 @@ type MenuItemMultiSelectTagProps = { className?: string; isKeySelected?: boolean; onClick?: () => void; - color: ThemeColor; + color: ThemeColor | 'transparent'; text: string; };