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)
This commit is contained in:
Baptiste Devessier
2024-11-28 18:03:24 +01:00
committed by GitHub
parent 3573d89c3c
commit d73dc1a728
32 changed files with 951 additions and 332 deletions

View File

@ -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 = ({
<HorizontalSeparator noMargin />
{editableFields.map((field) => (
<FormFieldInput
key={field.id}
recordFieldInputdId={field.id}
label={field.label}
value={formData[field.name] as string}
onChange={(value) => {
handleFieldChange(field.name, value);
}}
/>
))}
{editableFields.map((field) => {
const currentValue = formData[field.name] as JsonValue;
return (
<FormFieldInput
key={field.id}
defaultValue={currentValue}
field={field}
onPersist={(value) => {
handleFieldChange(field.name, value);
}}
VariablePicker={WorkflowVariablePicker}
/>
);
})}
</WorkflowEditGenericFormBase>
);
};

View File

@ -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 }) => (
<VariableTagInput
inputId="email-input"
<FormTextFieldInput
label="Email"
placeholder="Enter receiver email"
value={field.value}
onChange={(email) => {
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 }) => (
<VariableTagInput
inputId="email-subject-input"
<FormTextFieldInput
label="Subject"
placeholder="Enter email subject"
value={field.value}
onChange={(email) => {
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 }) => (
<VariableTagInput
inputId="email-body-input"
<FormTextFieldInput
label="Body"
placeholder="Enter email body"
value={field.value}
onChange={(email) => {
field.onChange(email);
readonly={actionOptions.readonly}
defaultValue={field.value}
onPersist={(value) => {
field.onChange(value);
handleSave();
}}
multiline
readonly={actionOptions.readonly}
VariablePicker={WorkflowVariablePicker}
/>
)}
/>

View File

@ -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 (
<VariableTagInput
<FormTextFieldInput
key={pathKey}
inputId={`input-${inputKey}`}
label={inputKey}
placeholder="Enter value"
defaultValue={inputValue ? String(inputValue) : ''}
readonly={actionOptions.readonly}
value={`${inputValue || ''}`}
onChange={(value) => handleInputChange(value, currentPath)}
onPersist={(value) => {
handleInputChange(value, currentPath);
}}
VariablePicker={WorkflowVariablePicker}
/>
);
}

View File

@ -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 (
<StyledSearchVariablesDropdownContainer
multiline={multiline}
readonly={disabled}
>
<SearchVariablesDropdown
inputId={inputId}
onVariableSelect={onVariableSelect}
disabled={disabled}
/>
</StyledSearchVariablesDropdownContainer>
);
};

View File

@ -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 = () => {

View File

@ -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 (
<StyledContainer>
{label && <StyledLabel>{label}</StyledLabel>}
<StyledInputContainer multiline={multiline}>
<StyledEditor multiline={multiline} readonly={readonly}>
<EditorContent className="editor-content" editor={editor} />
</StyledEditor>
<StyledSearchVariablesDropdownContainer
multiline={multiline}
readonly={readonly}
>
<SearchVariablesDropdown
inputId={inputId}
editor={editor}
disabled={readonly}
/>
</StyledSearchVariablesDropdownContainer>
</StyledInputContainer>
</StyledContainer>
);
};
export default VariableTagInput;

View File

@ -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');
});

View File

@ -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;
};

View File

@ -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;

View File

@ -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<ReturnType> {
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;

View File

@ -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);
});

View File

@ -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);
};