From b6508cc6158dba13a285ed44d5ed6e6e70a96d65 Mon Sep 17 00:00:00 2001 From: Baptiste Devessier Date: Wed, 18 Dec 2024 14:26:35 +0100 Subject: [PATCH] Add UUID form field input (#9121) --- .../components/FormFieldInput.tsx | 10 + .../components/FormUuidFieldInput.tsx | 125 +++++++++++ .../FormUuidFieldInput.stories.tsx | 212 ++++++++++++++++++ 3 files changed, 347 insertions(+) create mode 100644 packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormUuidFieldInput.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormUuidFieldInput.stories.tsx diff --git a/packages/twenty-front/src/modules/object-record/record-field/components/FormFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/components/FormFieldInput.tsx index 79fa537c95..6d7424d185 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/components/FormFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/components/FormFieldInput.tsx @@ -9,6 +9,7 @@ import { FormNumberFieldInput } from '@/object-record/record-field/form-types/co import { FormRawJsonFieldInput } from '@/object-record/record-field/form-types/components/FormRawJsonFieldInput'; import { FormSelectFieldInput } from '@/object-record/record-field/form-types/components/FormSelectFieldInput'; import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput'; +import { FormUuidFieldInput } from '@/object-record/record-field/form-types/components/FormUuidFieldInput'; import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent'; import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition'; import { @@ -30,6 +31,7 @@ import { isFieldNumber } from '@/object-record/record-field/types/guards/isField import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson'; import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect'; import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText'; +import { isFieldUuid } from '@/object-record/record-field/types/guards/isFieldUuid'; import { JsonValue } from 'type-fest'; type FormFieldInputProps = { @@ -128,5 +130,13 @@ export const FormFieldInput = ({ placeholder={field.label} VariablePicker={VariablePicker} /> + ) : isFieldUuid(field) ? ( + ) : null; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormUuidFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormUuidFieldInput.tsx new file mode 100644 index 0000000000..04f52fbc3a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormUuidFieldInput.tsx @@ -0,0 +1,125 @@ +import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer'; +import { FormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputInputContainer'; +import { FormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/FormFieldInputRowContainer'; +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'; + +const StyledInput = styled(TextInput)` + padding: ${({ theme }) => `${theme.spacing(1)} ${theme.spacing(2)}`}; +`; + +type FormUuidFieldInputProps = { + label?: string; + defaultValue: string | null | undefined; + placeholder: string; + onPersist: (value: string | null) => void; + readonly?: boolean; + VariablePicker?: VariablePickerComponent; +}; + +export const FormUuidFieldInput = ({ + label, + defaultValue, + placeholder, + onPersist, + VariablePicker, +}: FormUuidFieldInputProps) => { + 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 handleChange = (newText: string) => { + setDraftValue({ + type: 'static', + value: newText, + }); + + const trimmedNewText = newText.trim(); + + if (trimmedNewText === '') { + onPersist(null); + + return; + } + + onPersist(trimmedNewText); + }; + + const handleUnlinkVariable = () => { + setDraftValue({ + type: 'static', + value: '', + }); + + onPersist(null); + }; + + const handleVariableTagInsert = (variableName: string) => { + setDraftValue({ + type: 'variable', + value: variableName, + }); + + onPersist(variableName); + }; + + return ( + + {label ? {label} : null} + + + + {draftValue.type === 'static' ? ( + + ) : ( + + )} + + + {VariablePicker ? ( + + ) : null} + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormUuidFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormUuidFieldInput.stories.tsx new file mode 100644 index 0000000000..d4bb1f219c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormUuidFieldInput.stories.tsx @@ -0,0 +1,212 @@ +import { expect } from '@storybook/jest'; +import { Meta, StoryObj } from '@storybook/react'; +import { + fn, + userEvent, + waitFor, + waitForElementToBeRemoved, + within, +} from '@storybook/test'; +import { FormUuidFieldInput } from '../FormUuidFieldInput'; + +const meta: Meta = { + title: 'UI/Data/Field/Form/Input/FormUuidFieldInput', + component: FormUuidFieldInput, + args: {}, + argTypes: {}, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + label: 'UUID field', + placeholder: 'Enter UUID', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByText('UUID field'); + }, +}; + +export const SetUuidWithDashes: Story = { + args: { + label: 'UUID field', + placeholder: 'Enter UUID', + onPersist: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + const uuid = 'fc50139a-9047-467e-a313-700fd75700ac'; + + const input = await canvas.findByPlaceholderText('Enter UUID'); + expect(input).toBeVisible(); + + await userEvent.type(input, uuid); + + await waitFor(() => { + expect(args.onPersist).toHaveBeenCalledWith(uuid); + }); + }, +}; + +export const SetUuidWithoutDashes: Story = { + args: { + label: 'UUID field', + placeholder: 'Enter UUID', + onPersist: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + const uuid = 'fc50139a9047467ea313700fd75700ac'; + + const input = await canvas.findByPlaceholderText('Enter UUID'); + expect(input).toBeVisible(); + + await userEvent.type(input, uuid); + + await waitFor(() => { + expect(args.onPersist).toHaveBeenCalledWith(uuid); + }); + }, +}; + +export const SetInvalidUuidWithNoValidation: Story = { + args: { + label: 'UUID field', + placeholder: 'Enter UUID', + onPersist: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + const uuid = 'invalid'; + + const input = await canvas.findByPlaceholderText('Enter UUID'); + expect(input).toBeVisible(); + + await userEvent.type(input, uuid); + + await waitFor(() => { + expect(args.onPersist).toHaveBeenCalledWith(uuid); + }); + }, +}; + +export const TrimInputBeforePersisting: Story = { + args: { + label: 'UUID field', + placeholder: 'Enter UUID', + onPersist: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + const uuid = 'fc50139a9047467ea313700fd75700ac'; + + const input = await canvas.findByPlaceholderText('Enter UUID'); + expect(input).toBeVisible(); + + await userEvent.type(input, `{Space>2}${uuid}{Space>3}`); + + await waitFor(() => { + expect(args.onPersist).toHaveBeenCalledWith(uuid); + }); + }, +}; + +export const ClearField: Story = { + args: { + label: 'UUID field', + placeholder: 'Enter UUID', + onPersist: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + const input = await canvas.findByPlaceholderText('Enter UUID'); + expect(input).toBeVisible(); + + const uuid = 'test'; + + await userEvent.type(input, uuid); + + await waitFor(() => { + expect(args.onPersist).toHaveBeenCalledWith(uuid); + }); + + await Promise.all([ + userEvent.clear(input), + + waitFor(() => { + expect(args.onPersist).toHaveBeenCalledWith(null); + }), + ]); + }, +}; + +export const ReplaceStaticValueWithVariable: Story = { + args: { + label: 'UUID field', + placeholder: 'Enter UUID', + onPersist: fn(), + VariablePicker: ({ onVariableSelect }) => { + return ( + + ); + }, + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + const input = await canvas.findByPlaceholderText('Enter UUID'); + + expect(input).toBeVisible(); + expect(input).toHaveDisplayValue(''); + + const addVariableButton = await canvas.findByRole('button', { + name: 'Add variable', + }); + + const [, , variableTag] = await Promise.all([ + userEvent.click(addVariableButton), + + waitForElementToBeRemoved(input), + waitFor(() => { + const variableTag = canvas.getByText('test'); + expect(variableTag).toBeVisible(); + + return variableTag; + }), + waitFor(() => { + expect(args.onPersist).toHaveBeenCalledWith('{{test}}'); + }), + ]); + + const removeVariableButton = await canvas.findByTestId(/^remove-icon/); + + await Promise.all([ + userEvent.click(removeVariableButton), + + waitForElementToBeRemoved(variableTag), + waitFor(() => { + const input = canvas.getByPlaceholderText('Enter UUID'); + expect(input).toBeVisible(); + }), + waitFor(() => { + expect(args.onPersist).toHaveBeenCalledWith(null); + }), + ]); + }, +};