From d73dc1a7282ac39dda35f5f2510e03599103a94b Mon Sep 17 00:00:00 2001 From: Baptiste Devessier Date: Thu, 28 Nov 2024 18:03:24 +0100 Subject: [PATCH] Create form field number (#8634) - Refactor VariableTagInput to have a reusable low-level TipTap editor - Create three primitive form fields: - Text - Number - Boolean ## Notes - We should automatically recognize the placeholder to use for every FormFieldInput, as it's done for FieldInputs. ## Design decisions Our main challenge was for variables and inputs to be able to communicate between each other. We chose an API that adds some duplication but remains simple and doesn't rely on "hacks" to work. Common styles are centralized. ## Demo "Workflow" mode with variables: ![CleanShot 2024-11-26 at 10 43 25@2x](https://github.com/user-attachments/assets/cc17098a-ca27-4f97-b86a-bf88593e53db) FormFieldInput mode, without variables: ![CleanShot 2024-11-26 at 10 44 26@2x](https://github.com/user-attachments/assets/fec07c36-5944-4a1d-a863-516fd77c8f55) Behavior difference between fields that can contain variables and static content, and inputs that can have either a variable value or a static value: ![CleanShot 2024-11-26 at 10 47 13@2x](https://github.com/user-attachments/assets/1e562cd8-c362-46d0-b438-481215159da9) --- .../components/FormFieldInput.tsx | 60 +++-- .../components/FormBooleanFieldInput.tsx | 115 +++++++++ .../components/FormNumberFieldInput.tsx | 130 ++++++++++ .../components/FormTextFieldInput.tsx | 86 +++++++ .../StyledFormFieldInputContainer.tsx | 6 + .../StyledFormFieldInputInputContainer.tsx | 31 +++ .../StyledFormFieldInputRowContainer.tsx | 23 ++ .../components/TextVariableEditor.tsx | 71 ++++++ .../form-types/components/VariableChip.tsx | 27 ++ .../FormBooleanFieldInput.stories.tsx | 56 +++++ .../FormNumberFieldInput.stories.tsx | 38 +++ .../FormTextFieldInput.stories.tsx | 45 ++++ .../form-types/hooks/useTextVariableEditor.ts | 78 ++++++ .../form-types/types/EditingMode.ts | 1 + .../types/VariablePickerComponent.ts | 6 + .../input/hooks/useRegisterInputEvents.ts | 4 +- .../ui/field/input/components/TextInput.tsx | 7 +- .../ui/input/components/InputLabel.tsx | 10 + .../ui/input/components/TextInputV2.tsx | 12 +- .../WorkflowEditActionFormRecordCreate.tsx | 30 ++- .../WorkflowEditActionFormSendEmail.tsx | 40 +-- ...wEditActionFormServerlessFunctionInner.tsx | 13 +- .../components/WorkflowVariablePicker.tsx | 57 +++++ .../components/SearchVariablesDropdown.tsx | 11 +- .../components/VariableTagInput.tsx | 236 ------------------ .../__tests__/extractVariableLabel.test.ts | 13 + .../utils/extractVariableLabel.ts | 21 ++ .../utils/initializeEditorContent.ts | 4 +- .../search-variables/utils/variableTag.ts | 20 +- .../isStandaloneVariableString.test.ts | 17 ++ .../utils/isStandaloneVariableString.ts | 7 + .../workflow-record-crud-action-input.type.ts | 8 +- 32 files changed, 951 insertions(+), 332 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormBooleanFieldInput.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormNumberFieldInput.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormTextFieldInput.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-field/form-types/components/StyledFormFieldInputContainer.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-field/form-types/components/StyledFormFieldInputInputContainer.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-field/form-types/components/StyledFormFieldInputRowContainer.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-field/form-types/components/TextVariableEditor.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-field/form-types/components/VariableChip.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormBooleanFieldInput.stories.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormNumberFieldInput.stories.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormTextFieldInput.stories.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-field/form-types/hooks/useTextVariableEditor.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/form-types/types/EditingMode.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/form-types/types/VariablePickerComponent.ts create mode 100644 packages/twenty-front/src/modules/ui/input/components/InputLabel.tsx create mode 100644 packages/twenty-front/src/modules/workflow/components/WorkflowVariablePicker.tsx delete mode 100644 packages/twenty-front/src/modules/workflow/search-variables/components/VariableTagInput.tsx create mode 100644 packages/twenty-front/src/modules/workflow/search-variables/utils/__tests__/extractVariableLabel.test.ts create mode 100644 packages/twenty-front/src/modules/workflow/search-variables/utils/extractVariableLabel.ts create mode 100644 packages/twenty-front/src/modules/workflow/utils/__tests__/isStandaloneVariableString.test.ts create mode 100644 packages/twenty-front/src/modules/workflow/utils/isStandaloneVariableString.ts 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 = {