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

@ -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 (
<VariableTagInput
inputId={recordFieldInputdId}
label={label}
placeholder="Enter value (use {{variable}} for dynamic content)"
value={value}
onChange={onChange}
return isFieldNumber(field) ? (
<FormNumberFieldInput
key={field.id}
label={field.label}
defaultValue={defaultValue as string | number | undefined}
onPersist={onPersist}
placeholder={field.label}
VariablePicker={VariablePicker}
/>
);
) : isFieldBoolean(field) ? (
<FormBooleanFieldInput
key={field.id}
label={field.label}
defaultValue={defaultValue as string | boolean | undefined}
onPersist={onPersist}
VariablePicker={VariablePicker}
/>
) : isFieldText(field) ? (
<FormTextFieldInput
label={field.label}
defaultValue={defaultValue as string | undefined}
onPersist={onPersist}
placeholder={field.label}
VariablePicker={VariablePicker}
/>
) : null;
};

View File

@ -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 (
<StyledFormFieldInputContainer>
{label ? <InputLabel>{label}</InputLabel> : null}
<StyledFormFieldInputRowContainer>
<StyledFormFieldInputInputContainer
hasRightElement={isDefined(VariablePicker)}
>
{draftValue.type === 'static' ? (
<StyledBooleanInputContainer>
<BooleanInput
value={draftValue.value}
readonly={readonly}
onToggle={handleChange}
/>
</StyledBooleanInputContainer>
) : (
<VariableChip
rawVariableName={draftValue.value}
onRemove={handleUnlinkVariable}
/>
)}
</StyledFormFieldInputInputContainer>
{VariablePicker ? (
<VariablePicker
inputId={inputId}
onVariableSelect={handleVariableTagInsert}
/>
) : null}
</StyledFormFieldInputRowContainer>
</StyledFormFieldInputContainer>
);
};

View File

@ -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 (
<StyledFormFieldInputContainer>
{label ? <InputLabel htmlFor={inputId}>{label}</InputLabel> : null}
<StyledFormFieldInputRowContainer>
<StyledFormFieldInputInputContainer
hasRightElement={isDefined(VariablePicker)}
>
{draftValue.type === 'static' ? (
<StyledInput
inputId={inputId}
placeholder={placeholder}
value={draftValue.value}
copyButton={false}
hotkeyScope="record-create"
onChange={handleChange}
/>
) : (
<VariableChip
rawVariableName={draftValue.value}
onRemove={handleUnlinkVariable}
/>
)}
</StyledFormFieldInputInputContainer>
{VariablePicker ? (
<VariablePicker
inputId={inputId}
onVariableSelect={handleVariableTagInsert}
/>
) : null}
</StyledFormFieldInputRowContainer>
</StyledFormFieldInputContainer>
);
};

View File

@ -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 (
<StyledFormFieldInputContainer>
{label ? <InputLabel>{label}</InputLabel> : null}
<StyledFormFieldInputRowContainer multiline={multiline}>
<StyledFormFieldInputInputContainer
hasRightElement={isDefined(VariablePicker)}
multiline={multiline}
>
<TextVariableEditor
editor={editor}
multiline={multiline}
readonly={readonly}
/>
</StyledFormFieldInputInputContainer>
{VariablePicker ? (
<VariablePicker
inputId={inputId}
multiline={multiline}
onVariableSelect={handleVariableTagInsert}
/>
) : null}
</StyledFormFieldInputRowContainer>
</StyledFormFieldInputContainer>
);
};

View File

@ -0,0 +1,6 @@
import styled from '@emotion/styled';
export const StyledFormFieldInputContainer = styled.div`
display: flex;
flex-direction: column;
`;

View File

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

View File

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

View File

@ -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 (
<StyledEditor multiline={multiline} readonly={readonly}>
<EditorContent className="editor-content" editor={editor} />
</StyledEditor>
);
};

View File

@ -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 (
<StyledContainer>
<SortOrFilterChip
labelValue={extractVariableLabel(rawVariableName)}
onRemove={onRemove}
/>
</StyledContainer>
);
};

View File

@ -0,0 +1,56 @@
import { Meta, StoryObj } from '@storybook/react';
import { within } from '@storybook/test';
import { FormBooleanFieldInput } from '../FormBooleanFieldInput';
const meta: Meta<typeof FormBooleanFieldInput> = {
title: 'UI/Data/Field/Form/Input/FormBooleanFieldInput',
component: FormBooleanFieldInput,
args: {},
argTypes: {},
};
export default meta;
type Story = StoryObj<typeof FormBooleanFieldInput>;
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');
},
};

View File

@ -0,0 +1,38 @@
import { Meta, StoryObj } from '@storybook/react';
import { within } from '@storybook/test';
import { FormNumberFieldInput } from '../FormNumberFieldInput';
const meta: Meta<typeof FormNumberFieldInput> = {
title: 'UI/Data/Field/Form/Input/FormNumberFieldInput',
component: FormNumberFieldInput,
args: {},
argTypes: {},
};
export default meta;
type Story = StoryObj<typeof FormNumberFieldInput>;
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...');
},
};

View File

@ -0,0 +1,45 @@
import { Meta, StoryObj } from '@storybook/react';
import { within } from '@storybook/test';
import { FormTextFieldInput } from '../FormTextFieldInput';
const meta: Meta<typeof FormTextFieldInput> = {
title: 'UI/Data/Field/Form/Input/FormTextFieldInput',
component: FormTextFieldInput,
args: {},
argTypes: {},
};
export default meta;
type Story = StoryObj<typeof FormTextFieldInput>;
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$/);
},
};

View File

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

View File

@ -0,0 +1 @@
export type EditingMode = 'input' | 'variable';

View File

@ -0,0 +1,6 @@
export type VariablePickerComponent = React.FC<{
inputId: string;
disabled?: boolean;
multiline?: boolean;
onVariableSelect: (variableName: string) => void;
}>;

View File

@ -18,8 +18,8 @@ export const useRegisterInputEvents = <T>({
inputRef: React.RefObject<any>;
copyRef?: React.RefObject<any>;
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;