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 993dd2863..ba0aa8195 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,26 +1,50 @@ -import VariableTagInput from '@/workflow/search-variables/components/VariableTagInput'; +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { FormBooleanFieldInput } from '@/object-record/record-field/form-types/components/FormBooleanFieldInput'; +import { FormNumberFieldInput } from '@/object-record/record-field/form-types/components/FormNumberFieldInput'; +import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput'; +import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent'; +import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean'; +import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber'; +import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText'; +import { JsonValue } from 'type-fest'; type FormFieldInputProps = { - recordFieldInputdId: string; - label: string; - value: string; - onChange: (value: string) => void; - isReadOnly?: boolean; + field: FieldMetadataItem; + defaultValue: JsonValue; + onPersist: (value: JsonValue) => void; + VariablePicker?: VariablePickerComponent; }; export const FormFieldInput = ({ - recordFieldInputdId, - label, - onChange, - value, + field, + defaultValue, + onPersist, + VariablePicker, }: FormFieldInputProps) => { - return ( - - ); + ) : isFieldBoolean(field) ? ( + + ) : isFieldText(field) ? ( + + ) : null; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormBooleanFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormBooleanFieldInput.tsx new file mode 100644 index 000000000..660c076c0 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormBooleanFieldInput.tsx @@ -0,0 +1,115 @@ +import { StyledFormFieldInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputContainer'; +import { StyledFormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputInputContainer'; +import { StyledFormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputRowContainer'; +import { VariableChip } from '@/object-record/record-field/form-types/components/VariableChip'; +import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent'; +import { BooleanInput } from '@/ui/field/input/components/BooleanInput'; +import { InputLabel } from '@/ui/input/components/InputLabel'; +import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString'; +import styled from '@emotion/styled'; +import { useId, useState } from 'react'; +import { isDefined } from 'twenty-ui'; + +const StyledBooleanInputContainer = styled.div` + padding-inline: ${({ theme }) => theme.spacing(2)}; +`; + +type FormBooleanFieldInputProps = { + label?: string; + defaultValue: boolean | string | undefined; + onPersist: (value: boolean | null | string) => void; + VariablePicker?: VariablePickerComponent; + readonly?: boolean; +}; + +export const FormBooleanFieldInput = ({ + label, + defaultValue, + onPersist, + readonly, + VariablePicker, +}: FormBooleanFieldInputProps) => { + const inputId = useId(); + + const [draftValue, setDraftValue] = useState< + | { + type: 'static'; + value: boolean; + } + | { + type: 'variable'; + value: string; + } + >( + isStandaloneVariableString(defaultValue) + ? { + type: 'variable', + value: defaultValue, + } + : { + type: 'static', + value: defaultValue ?? false, + }, + ); + + const handleChange = (newValue: boolean) => { + setDraftValue({ + type: 'static', + value: newValue, + }); + + onPersist(newValue); + }; + + const handleVariableTagInsert = (variableName: string) => { + setDraftValue({ + type: 'variable', + value: variableName, + }); + + onPersist(variableName); + }; + + const handleUnlinkVariable = () => { + setDraftValue({ + type: 'static', + value: false, + }); + + onPersist(false); + }; + + return ( + + {label ? {label} : null} + + + + {draftValue.type === 'static' ? ( + + + + ) : ( + + )} + + + {VariablePicker ? ( + + ) : null} + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormNumberFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormNumberFieldInput.tsx new file mode 100644 index 000000000..fdbb077ee --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormNumberFieldInput.tsx @@ -0,0 +1,130 @@ +import { StyledFormFieldInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputContainer'; +import { StyledFormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputInputContainer'; +import { StyledFormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputRowContainer'; +import { VariableChip } from '@/object-record/record-field/form-types/components/VariableChip'; +import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent'; +import { TextInput } from '@/ui/field/input/components/TextInput'; +import { InputLabel } from '@/ui/input/components/InputLabel'; +import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString'; +import styled from '@emotion/styled'; +import { useId, useState } from 'react'; +import { isDefined } from 'twenty-ui'; +import { + canBeCastAsNumberOrNull, + castAsNumberOrNull, +} from '~/utils/cast-as-number-or-null'; + +const StyledInput = styled(TextInput)` + padding: ${({ theme }) => `${theme.spacing(1)} ${theme.spacing(2)}`}; +`; + +type FormNumberFieldInputProps = { + label?: string; + placeholder: string; + defaultValue: number | string | undefined; + onPersist: (value: number | null | string) => void; + VariablePicker?: VariablePickerComponent; +}; + +export const FormNumberFieldInput = ({ + label, + placeholder, + defaultValue, + onPersist, + VariablePicker, +}: FormNumberFieldInputProps) => { + const inputId = useId(); + + const [draftValue, setDraftValue] = useState< + | { + type: 'static'; + value: string; + } + | { + type: 'variable'; + value: string; + } + >( + isStandaloneVariableString(defaultValue) + ? { + type: 'variable', + value: defaultValue, + } + : { + type: 'static', + value: isDefined(defaultValue) ? String(defaultValue) : '', + }, + ); + + const persistNumber = (newValue: string) => { + if (!canBeCastAsNumberOrNull(newValue)) { + return; + } + + const castedValue = castAsNumberOrNull(newValue); + + onPersist(castedValue); + }; + + const handleChange = (newText: string) => { + setDraftValue({ + type: 'static', + value: newText, + }); + + persistNumber(newText.trim()); + }; + + const handleUnlinkVariable = () => { + setDraftValue({ + type: 'static', + value: '', + }); + + onPersist(null); + }; + + const handleVariableTagInsert = (variableName: string) => { + setDraftValue({ + type: 'variable', + value: variableName, + }); + + onPersist(variableName); + }; + + return ( + + {label ? {label} : null} + + + + {draftValue.type === 'static' ? ( + + ) : ( + + )} + + + {VariablePicker ? ( + + ) : null} + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormTextFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormTextFieldInput.tsx new file mode 100644 index 000000000..8916a918b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormTextFieldInput.tsx @@ -0,0 +1,86 @@ +import { StyledFormFieldInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputContainer'; +import { StyledFormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputInputContainer'; +import { StyledFormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputRowContainer'; +import { TextVariableEditor } from '@/object-record/record-field/form-types/components/TextVariableEditor'; +import { useTextVariableEditor } from '@/object-record/record-field/form-types/hooks/useTextVariableEditor'; +import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent'; +import { InputLabel } from '@/ui/input/components/InputLabel'; +import { parseEditorContent } from '@/workflow/search-variables/utils/parseEditorContent'; +import { useId } from 'react'; +import { isDefined } from 'twenty-ui'; + +type FormTextFieldInputProps = { + label?: string; + defaultValue: string | undefined; + placeholder: string; + onPersist: (value: string) => void; + multiline?: boolean; + readonly?: boolean; + VariablePicker?: VariablePickerComponent; +}; + +export const FormTextFieldInput = ({ + label, + defaultValue, + placeholder, + onPersist, + multiline, + readonly, + VariablePicker, +}: FormTextFieldInputProps) => { + const inputId = useId(); + + const editor = useTextVariableEditor({ + placeholder, + multiline, + readonly, + defaultValue, + onUpdate: (editor) => { + const jsonContent = editor.getJSON(); + const parsedContent = parseEditorContent(jsonContent); + + onPersist(parsedContent); + }, + }); + + const handleVariableTagInsert = (variableName: string) => { + if (!isDefined(editor)) { + throw new Error( + 'Expected the editor to be defined when a variable is selected', + ); + } + + editor.commands.insertVariableTag(variableName); + }; + + if (!isDefined(editor)) { + return null; + } + + return ( + + {label ? {label} : null} + + + + + + + {VariablePicker ? ( + + ) : null} + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/StyledFormFieldInputContainer.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/StyledFormFieldInputContainer.tsx new file mode 100644 index 000000000..5615ab80b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/StyledFormFieldInputContainer.tsx @@ -0,0 +1,6 @@ +import styled from '@emotion/styled'; + +export const StyledFormFieldInputContainer = styled.div` + display: flex; + flex-direction: column; +`; diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/StyledFormFieldInputInputContainer.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/StyledFormFieldInputInputContainer.tsx new file mode 100644 index 000000000..43fb3a192 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/StyledFormFieldInputInputContainer.tsx @@ -0,0 +1,31 @@ +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; + +export const StyledFormFieldInputInputContainer = styled.div<{ + hasRightElement: boolean; + multiline?: boolean; + readonly?: boolean; +}>` + background-color: ${({ theme }) => theme.background.transparent.lighter}; + border: 1px solid ${({ theme }) => theme.border.color.medium}; + border-top-left-radius: ${({ theme }) => theme.border.radius.sm}; + border-bottom-left-radius: ${({ theme }) => theme.border.radius.sm}; + + ${({ multiline, hasRightElement, theme }) => + multiline || !hasRightElement + ? css` + border-right: auto; + border-bottom-right-radius: ${theme.border.radius.sm}; + border-top-right-radius: ${theme.border.radius.sm}; + ` + : css` + border-right: none; + border-bottom-right-radius: none; + border-top-right-radius: none; + `} + + box-sizing: border-box; + display: flex; + overflow: ${({ multiline }) => (multiline ? 'auto' : 'hidden')}; + width: 100%; +`; diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/StyledFormFieldInputRowContainer.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/StyledFormFieldInputRowContainer.tsx new file mode 100644 index 000000000..527e3d18f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/StyledFormFieldInputRowContainer.tsx @@ -0,0 +1,23 @@ +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; + +export const LINE_HEIGHT = 24; + +export const StyledFormFieldInputRowContainer = styled.div<{ + multiline?: boolean; +}>` + display: flex; + flex-direction: row; + position: relative; + + ${({ multiline }) => + multiline + ? css` + line-height: ${LINE_HEIGHT}px; + min-height: ${3 * LINE_HEIGHT}px; + max-height: ${5 * LINE_HEIGHT}px; + ` + : css` + height: 32px; + `} +`; diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/TextVariableEditor.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/TextVariableEditor.tsx new file mode 100644 index 000000000..29df0894f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/TextVariableEditor.tsx @@ -0,0 +1,71 @@ +import styled from '@emotion/styled'; +import { Editor, EditorContent } from '@tiptap/react'; + +const StyledEditor = styled.div<{ multiline?: boolean; readonly?: boolean }>` + width: 100%; + display: flex; + box-sizing: border-box; + padding-right: ${({ multiline, theme }) => + multiline ? theme.spacing(4) : undefined}; + + .editor-content { + width: 100%; + } + + .tiptap { + padding: ${({ theme }) => `${theme.spacing(1)} ${theme.spacing(2)}`}; + box-sizing: border-box; + display: flex; + height: 100%; + overflow: ${({ multiline }) => (multiline ? 'auto' : 'hidden')}; + color: ${({ theme, readonly }) => + readonly ? theme.font.color.light : theme.font.color.primary}; + font-family: ${({ theme }) => theme.font.family}; + font-weight: ${({ theme }) => theme.font.weight.regular}; + border: none !important; + align-items: ${({ multiline }) => (multiline ? 'top' : 'center')}; + white-space: ${({ multiline }) => (multiline ? 'pre' : 'nowrap')}; + + p.is-editor-empty:first-of-type::before { + content: attr(data-placeholder); + color: ${({ theme }) => theme.font.color.light}; + font-weight: ${({ theme }) => theme.font.weight.medium}; + float: left; + height: 0; + pointer-events: none; + } + + p { + margin: 0; + } + + .variable-tag { + background-color: ${({ theme }) => theme.color.blue10}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + color: ${({ theme }) => theme.color.blue}; + padding: ${({ theme }) => theme.spacing(1)}; + } + } + + .ProseMirror-focused { + outline: none; + } +`; + +type TextVariableEditorProps = { + multiline: boolean | undefined; + readonly: boolean | undefined; + editor: Editor; +}; + +export const TextVariableEditor = ({ + multiline, + readonly, + editor, +}: TextVariableEditorProps) => { + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/VariableChip.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/VariableChip.tsx new file mode 100644 index 000000000..15273f1af --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/VariableChip.tsx @@ -0,0 +1,27 @@ +import { SortOrFilterChip } from '@/views/components/SortOrFilterChip'; +import { extractVariableLabel } from '@/workflow/search-variables/utils/extractVariableLabel'; +import styled from '@emotion/styled'; + +export const StyledContainer = styled.div` + align-items: center; + display: flex; +`; + +type VariableChipProps = { + rawVariableName: string; + onRemove: () => void; +}; + +export const VariableChip = ({ + rawVariableName, + onRemove, +}: VariableChipProps) => { + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormBooleanFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormBooleanFieldInput.stories.tsx new file mode 100644 index 000000000..8ae79ea62 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormBooleanFieldInput.stories.tsx @@ -0,0 +1,56 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { within } from '@storybook/test'; +import { FormBooleanFieldInput } from '../FormBooleanFieldInput'; + +const meta: Meta = { + title: 'UI/Data/Field/Form/Input/FormBooleanFieldInput', + component: FormBooleanFieldInput, + args: {}, + argTypes: {}, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {}, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByText('False'); + }, +}; + +export const WithLabel: Story = { + args: { + label: 'Boolean', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByText('Boolean'); + }, +}; + +export const TrueByDefault: Story = { + args: { + defaultValue: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByText('True'); + }, +}; + +export const FalseByDefault: Story = { + args: { + defaultValue: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByText('False'); + }, +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormNumberFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormNumberFieldInput.stories.tsx new file mode 100644 index 000000000..86fd52adc --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormNumberFieldInput.stories.tsx @@ -0,0 +1,38 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { within } from '@storybook/test'; +import { FormNumberFieldInput } from '../FormNumberFieldInput'; + +const meta: Meta = { + title: 'UI/Data/Field/Form/Input/FormNumberFieldInput', + component: FormNumberFieldInput, + args: {}, + argTypes: {}, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + placeholder: 'Number field...', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByPlaceholderText('Number field...'); + }, +}; + +export const WithLabel: Story = { + args: { + label: 'Number', + placeholder: 'Number field...', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByText('Number'); + await canvas.findByPlaceholderText('Number field...'); + }, +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormTextFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormTextFieldInput.stories.tsx new file mode 100644 index 000000000..ec0aada4c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormTextFieldInput.stories.tsx @@ -0,0 +1,45 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { within } from '@storybook/test'; +import { FormTextFieldInput } from '../FormTextFieldInput'; + +const meta: Meta = { + title: 'UI/Data/Field/Form/Input/FormTextFieldInput', + component: FormTextFieldInput, + args: {}, + argTypes: {}, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + placeholder: 'Text field...', + }, +}; + +export const WithLabel: Story = { + args: { + label: 'Text', + placeholder: 'Text field...', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByText(/^Text$/); + }, +}; + +export const Multiline: Story = { + args: { + label: 'Text', + placeholder: 'Text field...', + multiline: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByText(/^Text$/); + }, +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/hooks/useTextVariableEditor.ts b/packages/twenty-front/src/modules/object-record/record-field/form-types/hooks/useTextVariableEditor.ts new file mode 100644 index 000000000..bbb938474 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/hooks/useTextVariableEditor.ts @@ -0,0 +1,78 @@ +import { initializeEditorContent } from '@/workflow/search-variables/utils/initializeEditorContent'; +import { VariableTag } from '@/workflow/search-variables/utils/variableTag'; +import Document from '@tiptap/extension-document'; +import HardBreak from '@tiptap/extension-hard-break'; +import Paragraph from '@tiptap/extension-paragraph'; +import { default as Placeholder } from '@tiptap/extension-placeholder'; +import Text from '@tiptap/extension-text'; +import { Editor, useEditor } from '@tiptap/react'; +import { isDefined } from 'twenty-ui'; + +type UseTextVariableEditorProps = { + placeholder: string | undefined; + multiline: boolean | undefined; + readonly: boolean | undefined; + defaultValue: string | undefined; + onUpdate: (editor: Editor) => void; +}; + +export const useTextVariableEditor = ({ + placeholder, + multiline, + readonly, + defaultValue, + onUpdate, +}: UseTextVariableEditorProps) => { + const editor = useEditor({ + extensions: [ + Document, + Paragraph, + Text, + Placeholder.configure({ + placeholder, + }), + VariableTag, + ...(multiline + ? [ + HardBreak.configure({ + keepMarks: false, + }), + ] + : []), + ], + editable: !readonly, + onCreate: ({ editor }) => { + if (isDefined(defaultValue)) { + initializeEditorContent(editor, defaultValue); + } + }, + onUpdate: ({ editor }) => { + onUpdate(editor); + }, + editorProps: { + handleKeyDown: (view, event) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + + const { state } = view; + const { tr } = state; + + // Insert hard break using the view's state and dispatch + const transaction = tr.replaceSelectionWith( + state.schema.nodes.hardBreak.create(), + ); + + view.dispatch(transaction); + + return true; + } + return false; + }, + }, + enableInputRules: false, + enablePasteRules: false, + injectCSS: false, + }); + + return editor; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/types/EditingMode.ts b/packages/twenty-front/src/modules/object-record/record-field/form-types/types/EditingMode.ts new file mode 100644 index 000000000..a80a00204 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/types/EditingMode.ts @@ -0,0 +1 @@ +export type EditingMode = 'input' | 'variable'; diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/types/VariablePickerComponent.ts b/packages/twenty-front/src/modules/object-record/record-field/form-types/types/VariablePickerComponent.ts new file mode 100644 index 000000000..8e4d4371e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/types/VariablePickerComponent.ts @@ -0,0 +1,6 @@ +export type VariablePickerComponent = React.FC<{ + inputId: string; + disabled?: boolean; + multiline?: boolean; + onVariableSelect: (variableName: string) => void; +}>; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents.ts index c4ce8f03a..8e41832d2 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents.ts @@ -18,8 +18,8 @@ export const useRegisterInputEvents = ({ inputRef: React.RefObject; copyRef?: React.RefObject; inputValue: T; - onEscape: (inputValue: T) => void; - onEnter: (inputValue: T) => void; + onEscape?: (inputValue: T) => void; + onEnter?: (inputValue: T) => void; onTab?: (inputValue: T) => void; onShiftTab?: (inputValue: T) => void; onClickOutside?: (event: MouseEvent | TouchEvent, inputValue: T) => void; diff --git a/packages/twenty-front/src/modules/ui/field/input/components/TextInput.tsx b/packages/twenty-front/src/modules/ui/field/input/components/TextInput.tsx index a5afa4209..9699765b2 100644 --- a/packages/twenty-front/src/modules/ui/field/input/components/TextInput.tsx +++ b/packages/twenty-front/src/modules/ui/field/input/components/TextInput.tsx @@ -12,11 +12,12 @@ export const StyledTextInput = styled.input` `; type TextInputProps = { + inputId?: string; placeholder?: string; autoFocus?: boolean; value: string; - onEnter: (newText: string) => void; - onEscape: (newText: string) => void; + onEnter?: (newText: string) => void; + onEscape?: (newText: string) => void; onTab?: (newText: string) => void; onShiftTab?: (newText: string) => void; onClickOutside?: (event: MouseEvent | TouchEvent, inputValue: string) => void; @@ -35,6 +36,7 @@ const getValue = (value: string, shouldTrim: boolean) => { }; export const TextInput = ({ + inputId, placeholder, autoFocus, value, @@ -76,6 +78,7 @@ export const TextInput = ({ return ( <> theme.font.color.light}; + font-size: ${({ theme }) => theme.font.size.xs}; + font-weight: ${({ theme }) => theme.font.weight.semiBold}; + margin-bottom: ${({ theme }) => theme.spacing(1)}; +`; + +export const InputLabel = StyledLabel; diff --git a/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx b/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx index 557e67ced..d770d8a1b 100644 --- a/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx @@ -13,6 +13,7 @@ import { import { IconComponent, IconEye, IconEyeOff, RGBA } from 'twenty-ui'; import { useCombinedRefs } from '~/hooks/useCombinedRefs'; import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly'; +import { InputLabel } from './InputLabel'; const StyledContainer = styled.div< Pick @@ -22,13 +23,6 @@ const StyledContainer = styled.div< width: ${({ fullWidth }) => (fullWidth ? `100%` : 'auto')}; `; -const StyledLabel = styled.label` - color: ${({ theme }) => theme.font.color.light}; - font-size: ${({ theme }) => theme.font.size.xs}; - font-weight: ${({ theme }) => theme.font.weight.semiBold}; - margin-bottom: ${({ theme }) => theme.spacing(1)}; -`; - const StyledInputContainer = styled.div` display: flex; flex-direction: row; @@ -177,9 +171,9 @@ const TextInputV2Component = ( return ( {label && ( - + {label + (required ? '*' : '')} - + )} {!!LeftIcon && ( diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormRecordCreate.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormRecordCreate.tsx index aa86a84a8..7408e2465 100644 --- a/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormRecordCreate.tsx +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormRecordCreate.tsx @@ -2,6 +2,7 @@ import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilte import { FormFieldInput } from '@/object-record/record-field/components/FormFieldInput'; import { Select, SelectOption } from '@/ui/input/components/Select'; import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase'; +import { WorkflowVariablePicker } from '@/workflow/components/WorkflowVariablePicker'; import { WorkflowRecordCreateAction } from '@/workflow/types/Workflow'; import { useTheme } from '@emotion/react'; import { useEffect, useState } from 'react'; @@ -11,6 +12,7 @@ import { isDefined, useIcons, } from 'twenty-ui'; +import { JsonValue } from 'type-fest'; import { useDebouncedCallback } from 'use-debounce'; import { FieldMetadataType } from '~/generated/graphql'; @@ -55,7 +57,7 @@ export const WorkflowEditActionFormRecordCreate = ({ const handleFieldChange = ( fieldName: keyof SendEmailFormData, - updatedValue: string, + updatedValue: JsonValue, ) => { const newFormData: SendEmailFormData = { ...formData, @@ -163,17 +165,21 @@ export const WorkflowEditActionFormRecordCreate = ({ - {editableFields.map((field) => ( - { - handleFieldChange(field.name, value); - }} - /> - ))} + {editableFields.map((field) => { + const currentValue = formData[field.name] as JsonValue; + + return ( + { + handleFieldChange(field.name, value); + }} + VariablePicker={WorkflowVariablePicker} + /> + ); + })} ); }; diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormSendEmail.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormSendEmail.tsx index c4b77b000..7e0608f33 100644 --- a/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormSendEmail.tsx +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormSendEmail.tsx @@ -2,10 +2,11 @@ import { GMAIL_SEND_SCOPE } from '@/accounts/constants/GmailSendScope'; import { ConnectedAccount } from '@/accounts/types/ConnectedAccount'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; +import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput'; import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth'; import { Select, SelectOption } from '@/ui/input/components/Select'; import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase'; -import { VariableTagInput } from '@/workflow/search-variables/components/VariableTagInput'; +import { WorkflowVariablePicker } from '@/workflow/components/WorkflowVariablePicker'; import { workflowIdState } from '@/workflow/states/workflowIdState'; import { WorkflowSendEmailAction } from '@/workflow/types/Workflow'; import { useTheme } from '@emotion/react'; @@ -214,16 +215,16 @@ export const WorkflowEditActionFormSendEmail = ({ name="email" control={form.control} render={({ field }) => ( - { - field.onChange(email); + readonly={actionOptions.readonly} + defaultValue={field.value} + onPersist={(value) => { + field.onChange(value); handleSave(); }} - readonly={actionOptions.readonly} + VariablePicker={WorkflowVariablePicker} /> )} /> @@ -231,16 +232,16 @@ export const WorkflowEditActionFormSendEmail = ({ name="subject" control={form.control} render={({ field }) => ( - { - field.onChange(email); + readonly={actionOptions.readonly} + defaultValue={field.value} + onPersist={(value) => { + field.onChange(value); handleSave(); }} - readonly={actionOptions.readonly} + VariablePicker={WorkflowVariablePicker} /> )} /> @@ -248,17 +249,16 @@ export const WorkflowEditActionFormSendEmail = ({ name="body" control={form.control} render={({ field }) => ( - { - field.onChange(email); + readonly={actionOptions.readonly} + defaultValue={field.value} + onPersist={(value) => { + field.onChange(value); handleSave(); }} - multiline - readonly={actionOptions.readonly} + VariablePicker={WorkflowVariablePicker} /> )} /> diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormServerlessFunctionInner.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormServerlessFunctionInner.tsx index 0ce875ed3..5e45a9555 100644 --- a/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormServerlessFunctionInner.tsx +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormServerlessFunctionInner.tsx @@ -1,7 +1,8 @@ +import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput'; import { useGetManyServerlessFunctions } from '@/settings/serverless-functions/hooks/useGetManyServerlessFunctions'; import { Select, SelectOption } from '@/ui/input/components/Select'; import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase'; -import VariableTagInput from '@/workflow/search-variables/components/VariableTagInput'; +import { WorkflowVariablePicker } from '@/workflow/components/WorkflowVariablePicker'; import { FunctionInput } from '@/workflow/types/FunctionInput'; import { WorkflowCodeAction } from '@/workflow/types/Workflow'; import { getDefaultFunctionInputFromInputSchema } from '@/workflow/utils/getDefaultFunctionInputFromInputSchema'; @@ -203,14 +204,16 @@ export const WorkflowEditActionFormServerlessFunctionInner = ({ ); } else { return ( - handleInputChange(value, currentPath)} + onPersist={(value) => { + handleInputChange(value, currentPath); + }} + VariablePicker={WorkflowVariablePicker} /> ); } diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowVariablePicker.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowVariablePicker.tsx new file mode 100644 index 000000000..12bf787ac --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowVariablePicker.tsx @@ -0,0 +1,57 @@ +import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent'; +import SearchVariablesDropdown from '@/workflow/search-variables/components/SearchVariablesDropdown'; +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; + +export const StyledSearchVariablesDropdownContainer = styled.div<{ + multiline?: boolean; + readonly?: boolean; +}>` + align-items: center; + display: flex; + justify-content: center; + + ${({ theme, readonly }) => + !readonly && + css` + :hover { + background-color: ${theme.background.transparent.light}; + } + `} + + ${({ theme, multiline }) => + multiline + ? css` + border-radius: ${theme.border.radius.sm}; + padding: ${theme.spacing(0.5)} ${theme.spacing(0)}; + position: absolute; + right: ${theme.spacing(0)}; + top: ${theme.spacing(0)}; + ` + : css` + background-color: ${theme.background.transparent.lighter}; + border-top-right-radius: ${theme.border.radius.sm}; + border-bottom-right-radius: ${theme.border.radius.sm}; + border: 1px solid ${theme.border.color.medium}; + `} +`; + +export const WorkflowVariablePicker: VariablePickerComponent = ({ + inputId, + disabled, + multiline, + onVariableSelect, +}) => { + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdown.tsx b/packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdown.tsx index 584161a41..b20d6695b 100644 --- a/packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdown.tsx +++ b/packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdown.tsx @@ -9,7 +9,6 @@ import { useAvailableVariablesInWorkflowStep } from '@/workflow/search-variables import { StepOutputSchema } from '@/workflow/search-variables/types/StepOutputSchema'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { Editor } from '@tiptap/react'; import { useState } from 'react'; import { IconVariablePlus } from 'twenty-ui'; @@ -29,11 +28,11 @@ const StyledDropdownVariableButtonContainer = styled( const SearchVariablesDropdown = ({ inputId, - editor, + onVariableSelect, disabled, }: { inputId: string; - editor: Editor; + onVariableSelect: (variableName: string) => void; disabled?: boolean; }) => { const theme = useTheme(); @@ -52,10 +51,6 @@ const SearchVariablesDropdown = ({ StepOutputSchema | undefined >(initialStep); - const insertVariableTag = (variable: string) => { - editor.commands.insertVariableTag(variable); - }; - const handleStepSelect = (stepId: string) => { setSelectedStep( availableVariablesInWorkflowStep.find((step) => step.id === stepId), @@ -63,7 +58,7 @@ const SearchVariablesDropdown = ({ }; const handleSubItemSelect = (subItem: string) => { - insertVariableTag(subItem); + onVariableSelect(subItem); }; const handleBack = () => { diff --git a/packages/twenty-front/src/modules/workflow/search-variables/components/VariableTagInput.tsx b/packages/twenty-front/src/modules/workflow/search-variables/components/VariableTagInput.tsx deleted file mode 100644 index 055f5e9b6..000000000 --- a/packages/twenty-front/src/modules/workflow/search-variables/components/VariableTagInput.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import SearchVariablesDropdown from '@/workflow/search-variables/components/SearchVariablesDropdown'; -import { initializeEditorContent } from '@/workflow/search-variables/utils/initializeEditorContent'; -import { parseEditorContent } from '@/workflow/search-variables/utils/parseEditorContent'; -import { VariableTag } from '@/workflow/search-variables/utils/variableTag'; -import styled from '@emotion/styled'; -import Document from '@tiptap/extension-document'; -import HardBreak from '@tiptap/extension-hard-break'; -import Paragraph from '@tiptap/extension-paragraph'; -import Placeholder from '@tiptap/extension-placeholder'; -import Text from '@tiptap/extension-text'; -import { EditorContent, useEditor } from '@tiptap/react'; -import { isDefined } from 'twenty-ui'; -import { useDebouncedCallback } from 'use-debounce'; - -const LINE_HEIGHT = 24; - -const StyledContainer = styled.div` - display: inline-flex; - flex-direction: column; -`; - -const StyledLabel = styled.div` - color: ${({ theme }) => theme.font.color.light}; - font-size: ${({ theme }) => theme.font.size.md}; - font-weight: ${({ theme }) => theme.font.weight.semiBold}; - margin-bottom: ${({ theme }) => theme.spacing(1)}; -`; - -const StyledInputContainer = styled.div<{ - multiline?: boolean; -}>` - display: flex; - flex-direction: row; - position: relative; - line-height: ${({ multiline }) => (multiline ? `${LINE_HEIGHT}px` : 'auto')}; - min-height: ${({ multiline }) => - multiline ? `${3 * LINE_HEIGHT}px` : 'auto'}; - max-height: ${({ multiline }) => - multiline ? `${5 * LINE_HEIGHT}px` : 'auto'}; -`; - -const StyledSearchVariablesDropdownContainer = styled.div<{ - multiline?: boolean; - readonly?: boolean; -}>` - align-items: center; - display: flex; - justify-content: center; - - ${({ theme, readonly }) => - !readonly && - ` - :hover { - background-color: ${theme.background.transparent.light}; - }`} - - ${({ theme, multiline }) => - multiline - ? ` - position: absolute; - top: ${theme.spacing(0)}; - right: ${theme.spacing(0)}; - padding: ${theme.spacing(0.5)} ${theme.spacing(0)}; - border-radius: ${theme.border.radius.sm}; - ` - : ` - background-color: ${theme.background.transparent.lighter}; - border-top-right-radius: ${theme.border.radius.sm}; - border-bottom-right-radius: ${theme.border.radius.sm}; - border: 1px solid ${theme.border.color.medium}; - `} -`; - -const StyledEditor = styled.div<{ multiline?: boolean; readonly?: boolean }>` - display: flex; - width: 100%; - border: 1px solid ${({ theme }) => theme.border.color.medium}; - border-bottom-left-radius: ${({ theme }) => theme.border.radius.sm}; - border-top-left-radius: ${({ theme }) => theme.border.radius.sm}; - box-sizing: border-box; - background-color: ${({ theme }) => theme.background.transparent.lighter}; - padding: ${({ theme }) => `${theme.spacing(1)} ${theme.spacing(2)}`}; - border-bottom-right-radius: ${({ multiline, theme }) => - multiline ? theme.border.radius.sm : 'none'}; - border-top-right-radius: ${({ multiline, theme }) => - multiline ? theme.border.radius.sm : 'none'}; - border-right: ${({ multiline }) => (multiline ? 'auto' : 'none')}; - padding-right: ${({ multiline, theme }) => - multiline ? theme.spacing(6) : theme.spacing(2)}; - overflow: ${({ multiline }) => (multiline ? 'auto' : 'hidden')}; - height: ${({ multiline }) => (multiline ? 'auto' : `${1.5 * LINE_HEIGHT}px`)}; - - .editor-content { - width: 100%; - } - - .tiptap { - display: flex; - height: 100%; - color: ${({ theme, readonly }) => - readonly ? theme.font.color.light : theme.font.color.primary}; - font-family: ${({ theme }) => theme.font.family}; - font-weight: ${({ theme }) => theme.font.weight.regular}; - border: none !important; - align-items: ${({ multiline }) => (multiline ? 'top' : 'center')}; - white-space: ${({ multiline }) => (multiline ? 'pre-wrap' : 'nowrap')}; - word-wrap: ${({ multiline }) => (multiline ? 'break-word' : 'normal')}; - - p.is-editor-empty:first-of-type::before { - content: attr(data-placeholder); - color: ${({ theme }) => theme.font.color.light}; - float: left; - height: 0; - pointer-events: none; - } - - p { - margin: 0; - } - - .variable-tag { - color: ${({ theme }) => theme.color.blue}; - background-color: ${({ theme }) => theme.color.blue10}; - padding: ${({ theme }) => theme.spacing(1)}; - border-radius: ${({ theme }) => theme.border.radius.sm}; - } - } - - .ProseMirror-focused { - outline: none; - } -`; - -interface VariableTagInputProps { - inputId: string; - label?: string; - value?: string; - placeholder?: string; - multiline?: boolean; - onChange?: (content: string) => void; - readonly?: boolean; -} - -export const VariableTagInput = ({ - inputId, - label, - value, - placeholder, - multiline, - onChange, - readonly, -}: VariableTagInputProps) => { - const deboucedOnUpdate = useDebouncedCallback((editor) => { - const jsonContent = editor.getJSON(); - const parsedContent = parseEditorContent(jsonContent); - onChange?.(parsedContent); - }, 500); - - const editor = useEditor({ - extensions: [ - Document, - Paragraph, - Text, - Placeholder.configure({ - placeholder, - }), - VariableTag, - ...(multiline - ? [ - HardBreak.configure({ - keepMarks: false, - }), - ] - : []), - ], - editable: !readonly, - onCreate: ({ editor }) => { - if (isDefined(value)) { - initializeEditorContent(editor, value); - } - }, - onUpdate: ({ editor }) => { - deboucedOnUpdate(editor); - }, - editorProps: { - handleKeyDown: (view, event) => { - if (event.key === 'Enter' && !event.shiftKey) { - event.preventDefault(); - - const { state } = view; - const { tr } = state; - - // Insert hard break using the view's state and dispatch - const transaction = tr.replaceSelectionWith( - state.schema.nodes.hardBreak.create(), - ); - - view.dispatch(transaction); - - return true; - } - return false; - }, - }, - enableInputRules: false, - enablePasteRules: false, - injectCSS: false, - }); - - if (!editor) { - return null; - } - - return ( - - {label && {label}} - - - - - - - - - - ); -}; - -export default VariableTagInput; diff --git a/packages/twenty-front/src/modules/workflow/search-variables/utils/__tests__/extractVariableLabel.test.ts b/packages/twenty-front/src/modules/workflow/search-variables/utils/__tests__/extractVariableLabel.test.ts new file mode 100644 index 000000000..e6e41bec0 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/search-variables/utils/__tests__/extractVariableLabel.test.ts @@ -0,0 +1,13 @@ +import { extractVariableLabel } from '../extractVariableLabel'; + +it('returns the last part of a properly formatted variable', () => { + const rawVariable = '{{a.b.c}}'; + + expect(extractVariableLabel(rawVariable)).toBe('c'); +}); + +it('stops on unclosed variables', () => { + const rawVariable = '{{ test {{a.b.c}}'; + + expect(extractVariableLabel(rawVariable)).toBe('c'); +}); diff --git a/packages/twenty-front/src/modules/workflow/search-variables/utils/extractVariableLabel.ts b/packages/twenty-front/src/modules/workflow/search-variables/utils/extractVariableLabel.ts new file mode 100644 index 000000000..4aff2c153 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/search-variables/utils/extractVariableLabel.ts @@ -0,0 +1,21 @@ +import { isDefined } from 'twenty-ui'; + +const CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX = /{{([^{}]+)}}/g; + +export const extractVariableLabel = (rawVariableName: string) => { + const variableWithoutBrackets = rawVariableName.replace( + CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, + (_, variableName) => { + return variableName; + }, + ); + + const parts = variableWithoutBrackets.split('.'); + const displayText = parts.at(-1); + + if (!isDefined(displayText)) { + throw new Error('Expected to find at least one splitted chunk.'); + } + + return displayText; +}; diff --git a/packages/twenty-front/src/modules/workflow/search-variables/utils/initializeEditorContent.ts b/packages/twenty-front/src/modules/workflow/search-variables/utils/initializeEditorContent.ts index 2adec8f48..e109ad5c1 100644 --- a/packages/twenty-front/src/modules/workflow/search-variables/utils/initializeEditorContent.ts +++ b/packages/twenty-front/src/modules/workflow/search-variables/utils/initializeEditorContent.ts @@ -1,13 +1,13 @@ import { isNonEmptyString } from '@sniptt/guards'; import { Editor } from '@tiptap/react'; -const REGEX_VARIABLE_TAG = /(\{\{[^}]+\}\})/; +const CAPTURE_VARIABLE_TAG_REGEX = /({{[^{}]+}})/; export const initializeEditorContent = (editor: Editor, content: string) => { const lines = content.split(/\n/); lines.forEach((line, index) => { - const parts = line.split(REGEX_VARIABLE_TAG); + const parts = line.split(CAPTURE_VARIABLE_TAG_REGEX); parts.forEach((part) => { if (part.length === 0) { return; diff --git a/packages/twenty-front/src/modules/workflow/search-variables/utils/variableTag.ts b/packages/twenty-front/src/modules/workflow/search-variables/utils/variableTag.ts index ec2361ee2..9888097dd 100644 --- a/packages/twenty-front/src/modules/workflow/search-variables/utils/variableTag.ts +++ b/packages/twenty-front/src/modules/workflow/search-variables/utils/variableTag.ts @@ -1,10 +1,11 @@ +import { extractVariableLabel } from '@/workflow/search-variables/utils/extractVariableLabel'; import { Node } from '@tiptap/core'; import { mergeAttributes } from '@tiptap/react'; declare module '@tiptap/core' { interface Commands { variableTag: { - insertVariableTag: (variable: string) => ReturnType; + insertVariableTag: (variableName: string) => ReturnType; }; } } @@ -29,15 +30,6 @@ export const VariableTag = Node.create({ renderHTML: ({ node, HTMLAttributes }) => { const variable = node.attrs.variable as string; - const variableWithoutBrackets = variable.replace( - /\{\{([^}]+)\}\}/g, - (_, variable) => { - return variable; - }, - ); - - const parts = variableWithoutBrackets.split('.'); - const displayText = parts[parts.length - 1]; return [ 'span', @@ -45,17 +37,17 @@ export const VariableTag = Node.create({ 'data-type': 'variableTag', class: 'variable-tag', }), - displayText, + extractVariableLabel(variable), ]; }, addCommands: () => ({ insertVariableTag: - (variable: string) => + (variableName: string) => ({ commands }) => { - commands.insertContent?.({ + commands.insertContent({ type: 'variableTag', - attrs: { variable }, + attrs: { variable: variableName }, }); return true; diff --git a/packages/twenty-front/src/modules/workflow/utils/__tests__/isStandaloneVariableString.test.ts b/packages/twenty-front/src/modules/workflow/utils/__tests__/isStandaloneVariableString.test.ts new file mode 100644 index 000000000..9a4b816c5 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/utils/__tests__/isStandaloneVariableString.test.ts @@ -0,0 +1,17 @@ +import { isStandaloneVariableString } from '../isStandaloneVariableString'; + +it('returns false if the provided value is not a string', () => { + expect(isStandaloneVariableString(42)).toBe(false); +}); + +it('returns true if the provided value is a valid variable definition string', () => { + expect(isStandaloneVariableString('{{ test.a.b.c }}')).toBe(true); +}); + +it('returns false if the provided value starts with blank spaces', () => { + expect(isStandaloneVariableString(' {{ test.a.b.c }}')).toBe(false); +}); + +it('returns false if the provided value ends with blank spaces', () => { + expect(isStandaloneVariableString('{{ test.a.b.c }} ')).toBe(false); +}); diff --git a/packages/twenty-front/src/modules/workflow/utils/isStandaloneVariableString.ts b/packages/twenty-front/src/modules/workflow/utils/isStandaloneVariableString.ts new file mode 100644 index 000000000..829c3bd89 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/utils/isStandaloneVariableString.ts @@ -0,0 +1,7 @@ +import { isString } from '@sniptt/guards'; + +const STANDALONE_VARIABLE_REGEX = /^{{[^{}]+}}$/; + +export const isStandaloneVariableString = (value: unknown): value is string => { + return isString(value) && STANDALONE_VARIABLE_REGEX.test(value); +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type.ts index 314eef3c3..357ac5603 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type.ts @@ -6,10 +6,10 @@ import { type ObjectRecord = Record; export enum WorkflowRecordCRUDType { - CREATE = 'create', - UPDATE = 'update', - DELETE = 'delete', - READ = 'read', + CREATE = 'CREATE', + UPDATE = 'UPDATE', + DELETE = 'DELETE', + READ = 'READ', } export type WorkflowCreateRecordActionInput = {