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:  FormFieldInput mode, without variables:  Behavior difference between fields that can contain variables and static content, and inputs that can have either a variable value or a static value: 
This commit is contained in:
committed by
GitHub
parent
3573d89c3c
commit
d73dc1a728
@ -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;
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,6 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
export const StyledFormFieldInputContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
@ -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%;
|
||||
`;
|
||||
@ -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;
|
||||
`}
|
||||
`;
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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');
|
||||
},
|
||||
};
|
||||
@ -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...');
|
||||
},
|
||||
};
|
||||
@ -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$/);
|
||||
},
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export type EditingMode = 'input' | 'variable';
|
||||
@ -0,0 +1,6 @@
|
||||
export type VariablePickerComponent = React.FC<{
|
||||
inputId: string;
|
||||
disabled?: boolean;
|
||||
multiline?: boolean;
|
||||
onVariableSelect: (variableName: string) => void;
|
||||
}>;
|
||||
@ -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;
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
<StyledTextInput
|
||||
id={inputId}
|
||||
autoComplete="off"
|
||||
ref={wrapperRef}
|
||||
placeholder={placeholder}
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
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)};
|
||||
`;
|
||||
|
||||
export const InputLabel = StyledLabel;
|
||||
@ -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<TextInputV2ComponentProps, 'fullWidth'>
|
||||
@ -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 (
|
||||
<StyledContainer className={className} fullWidth={fullWidth ?? false}>
|
||||
{label && (
|
||||
<StyledLabel htmlFor={inputId}>
|
||||
<InputLabel htmlFor={inputId}>
|
||||
{label + (required ? '*' : '')}
|
||||
</StyledLabel>
|
||||
</InputLabel>
|
||||
)}
|
||||
<StyledInputContainer>
|
||||
{!!LeftIcon && (
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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 = () => {
|
||||
|
||||
@ -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;
|
||||
@ -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');
|
||||
});
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
@ -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);
|
||||
};
|
||||
@ -6,10 +6,10 @@ import {
|
||||
type ObjectRecord = Record<string, any>;
|
||||
|
||||
export enum WorkflowRecordCRUDType {
|
||||
CREATE = 'create',
|
||||
UPDATE = 'update',
|
||||
DELETE = 'delete',
|
||||
READ = 'read',
|
||||
CREATE = 'CREATE',
|
||||
UPDATE = 'UPDATE',
|
||||
DELETE = 'DELETE',
|
||||
READ = 'READ',
|
||||
}
|
||||
|
||||
export type WorkflowCreateRecordActionInput = {
|
||||
|
||||
Reference in New Issue
Block a user