From 68662fa54378465368125f30fce004a46c02138b Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Mon, 22 Apr 2024 11:19:41 +0200 Subject: [PATCH] [refacto] Introduce stateless TextInputV2 (#5013) ## Context As discussed with @lucasbordeau and @charlesBochet we are looking at making low level UI components stateless when possible. Therefore TextInput should not handle a hotkey state. Instead hotkeys should be defined in the parent component (as done here in CreateProfile). Introducing here TextInputV2 that is stateless and that can already replace TextInput without any behaviour change everywhere it is used with `disableHotkey` prop. ## How was it tested? Locally + Storybook --------- Co-authored-by: Lucas Bordeau --- .../field/input/components/AddressInput.tsx | 17 +- .../modules/ui/input/components/TextInput.tsx | 192 ++---------------- .../ui/input/components/TextInputV2.tsx | 188 +++++++++++++++++ .../__stories__/TextInputV2.stories.tsx | 42 ++++ .../src/pages/auth/CreateProfile.tsx | 46 +++-- .../src/pages/auth/CreateWorkspace.tsx | 5 +- .../src/pages/auth/PasswordReset.tsx | 8 +- 7 files changed, 284 insertions(+), 214 deletions(-) create mode 100644 packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx create mode 100644 packages/twenty-front/src/modules/ui/input/components/__stories__/TextInputV2.stories.tsx diff --git a/packages/twenty-front/src/modules/ui/field/input/components/AddressInput.tsx b/packages/twenty-front/src/modules/ui/field/input/components/AddressInput.tsx index 46da4b2eb..5067405a4 100644 --- a/packages/twenty-front/src/modules/ui/field/input/components/AddressInput.tsx +++ b/packages/twenty-front/src/modules/ui/field/input/components/AddressInput.tsx @@ -5,7 +5,7 @@ import { Key } from 'ts-key-enum'; import { FieldAddressDraftValue } from '@/object-record/record-field/types/FieldInputDraftValue'; import { FieldAddressValue } from '@/object-record/record-field/types/FieldMetadata'; import { CountrySelect } from '@/ui/input/components/internal/country/components/CountrySelect'; -import { TextInput } from '@/ui/input/components/TextInput'; +import { TextInputV2 } from '@/ui/input/components/TextInputV2'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener'; import { isDefined } from '~/utils/isDefined'; @@ -179,7 +179,7 @@ export const AddressInput = ({ return ( - - - - - >` - display: inline-flex; - flex-direction: column; - width: ${({ fullWidth }) => (fullWidth ? `100%` : 'auto')}; -`; - -const StyledLabel = styled.span` - 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; - width: 100%; -`; - -const StyledInput = styled.input>` - background-color: ${({ theme }) => theme.background.transparent.lighter}; - border: 1px solid ${({ theme }) => theme.border.color.medium}; - border-bottom-left-radius: ${({ theme }) => theme.border.radius.sm}; - border-right: none; - border-top-left-radius: ${({ theme }) => theme.border.radius.sm}; - box-sizing: border-box; - color: ${({ theme }) => theme.font.color.primary}; - display: flex; - flex-grow: 1; - font-family: ${({ theme }) => theme.font.family}; - font-weight: ${({ theme }) => theme.font.weight.regular}; - height: 32px; - outline: none; - padding: ${({ theme }) => theme.spacing(2)}; - width: 100%; - - &::placeholder, - &::-webkit-input-placeholder { - color: ${({ theme }) => theme.font.color.light}; - font-family: ${({ theme }) => theme.font.family}; - font-weight: ${({ theme }) => theme.font.weight.medium}; - } - - &:disabled { - color: ${({ theme }) => theme.font.color.tertiary}; - } -`; - -const StyledErrorHelper = styled.div` - color: ${({ theme }) => theme.color.red}; - font-size: ${({ theme }) => theme.font.size.xs}; - padding: ${({ theme }) => theme.spacing(1)}; -`; - -const StyledTrailingIconContainer = styled.div` - align-items: center; - background-color: ${({ theme }) => theme.background.transparent.lighter}; - border: 1px solid ${({ theme }) => theme.border.color.medium}; - border-bottom-right-radius: ${({ theme }) => theme.border.radius.sm}; - border-left: none; - border-top-right-radius: ${({ theme }) => theme.border.radius.sm}; - display: flex; - justify-content: center; - padding-right: ${({ theme }) => theme.spacing(1)}; -`; - -const StyledTrailingIcon = styled.div` - align-items: center; - color: ${({ theme }) => theme.font.color.light}; - cursor: ${({ onClick }) => (onClick ? 'pointer' : 'default')}; - display: flex; - justify-content: center; -`; - -const INPUT_TYPE_PASSWORD = 'password'; - -export type TextInputComponentProps = Omit< - InputHTMLAttributes, - 'onChange' | 'onKeyDown' -> & { - className?: string; - label?: string; - onChange?: (text: string) => void; - fullWidth?: boolean; +export type TextInputComponentProps = TextInputV2ComponentProps & { disableHotkeys?: boolean; - error?: string; - RightIcon?: IconComponent; - onKeyDown?: (event: React.KeyboardEvent) => void; - onBlur?: () => void; }; -const TextInputComponent = ( - { - className, - label, - value, - onChange, - onFocus, - onBlur, - onKeyDown, - fullWidth, - error, - required, - type, - disableHotkeys = false, - autoFocus, - placeholder, - disabled, - tabIndex, - RightIcon, - }: TextInputComponentProps, - // eslint-disable-next-line @nx/workspace-component-props-naming - ref: ForwardedRef, -): JSX.Element => { - const theme = useTheme(); - +const TextInputComponent = ({ + onFocus, + onBlur, + disableHotkeys = false, + ...props +}: TextInputComponentProps): JSX.Element => { const inputRef = useRef(null); - const combinedRef = useCombinedRefs(ref, inputRef); const { goBackToPreviousHotkeyScope, @@ -167,57 +52,8 @@ const TextInputComponent = ( { enabled: !disableHotkeys }, ); - const [passwordVisible, setPasswordVisible] = useState(false); - - const handleTogglePasswordVisibility = () => { - setPasswordVisible(!passwordVisible); - }; - - return ( - - {label && {label + (required ? '*' : '')}} - - ) => { - onChange?.(event.target.value); - }} - onKeyDown={onKeyDown} - {...{ autoFocus, disabled, placeholder, required, value }} - /> - - {error && ( - - - - )} - {!error && type === INPUT_TYPE_PASSWORD && ( - - {passwordVisible ? ( - - ) : ( - - )} - - )} - {!error && type !== INPUT_TYPE_PASSWORD && !!RightIcon && ( - - - - )} - - - {error && {error}} - - ); + // eslint-disable-next-line react/jsx-props-no-spreading + return ; }; export const TextInput = forwardRef(TextInputComponent); diff --git a/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx b/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx new file mode 100644 index 000000000..395058083 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx @@ -0,0 +1,188 @@ +import { + ChangeEvent, + FocusEventHandler, + ForwardedRef, + forwardRef, + InputHTMLAttributes, + useRef, + useState, +} from 'react'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { IconAlertCircle, IconComponent, IconEye, IconEyeOff } from 'twenty-ui'; + +import { useCombinedRefs } from '~/hooks/useCombinedRefs'; + +const StyledContainer = styled.div< + Pick +>` + display: inline-flex; + flex-direction: column; + width: ${({ fullWidth }) => (fullWidth ? `100%` : 'auto')}; +`; + +const StyledLabel = styled.span` + 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; + width: 100%; +`; + +const StyledInput = styled.input>` + background-color: ${({ theme }) => theme.background.transparent.lighter}; + border: 1px solid ${({ theme }) => theme.border.color.medium}; + border-bottom-left-radius: ${({ theme }) => theme.border.radius.sm}; + border-right: none; + border-top-left-radius: ${({ theme }) => theme.border.radius.sm}; + box-sizing: border-box; + color: ${({ theme }) => theme.font.color.primary}; + display: flex; + flex-grow: 1; + font-family: ${({ theme }) => theme.font.family}; + font-weight: ${({ theme }) => theme.font.weight.regular}; + height: 32px; + outline: none; + padding: ${({ theme }) => theme.spacing(2)}; + width: 100%; + + &::placeholder, + &::-webkit-input-placeholder { + color: ${({ theme }) => theme.font.color.light}; + font-family: ${({ theme }) => theme.font.family}; + font-weight: ${({ theme }) => theme.font.weight.medium}; + } + + &:disabled { + color: ${({ theme }) => theme.font.color.tertiary}; + } +`; + +const StyledErrorHelper = styled.div` + color: ${({ theme }) => theme.color.red}; + font-size: ${({ theme }) => theme.font.size.xs}; + padding: ${({ theme }) => theme.spacing(1)}; +`; + +const StyledTrailingIconContainer = styled.div` + align-items: center; + background-color: ${({ theme }) => theme.background.transparent.lighter}; + border: 1px solid ${({ theme }) => theme.border.color.medium}; + border-bottom-right-radius: ${({ theme }) => theme.border.radius.sm}; + border-left: none; + border-top-right-radius: ${({ theme }) => theme.border.radius.sm}; + display: flex; + justify-content: center; + padding-right: ${({ theme }) => theme.spacing(1)}; +`; + +const StyledTrailingIcon = styled.div` + align-items: center; + color: ${({ theme }) => theme.font.color.light}; + cursor: ${({ onClick }) => (onClick ? 'pointer' : 'default')}; + display: flex; + justify-content: center; +`; + +const INPUT_TYPE_PASSWORD = 'password'; + +export type TextInputV2ComponentProps = Omit< + InputHTMLAttributes, + 'onChange' | 'onKeyDown' +> & { + className?: string; + label?: string; + onChange?: (text: string) => void; + fullWidth?: boolean; + error?: string; + RightIcon?: IconComponent; + onKeyDown?: (event: React.KeyboardEvent) => void; + onBlur?: FocusEventHandler; +}; + +const TextInputV2Component = ( + { + className, + label, + value, + onChange, + onFocus, + onBlur, + onKeyDown, + fullWidth, + error, + required, + type, + autoFocus, + placeholder, + disabled, + tabIndex, + RightIcon, + }: TextInputV2ComponentProps, + // eslint-disable-next-line @nx/workspace-component-props-naming + ref: ForwardedRef, +): JSX.Element => { + const theme = useTheme(); + + const inputRef = useRef(null); + const combinedRef = useCombinedRefs(ref, inputRef); + + const [passwordVisible, setPasswordVisible] = useState(false); + + const handleTogglePasswordVisibility = () => { + setPasswordVisible(!passwordVisible); + }; + + return ( + + {label && {label + (required ? '*' : '')}} + + ) => { + onChange?.(event.target.value); + }} + onKeyDown={onKeyDown} + {...{ autoFocus, disabled, placeholder, required, value }} + /> + + {error && ( + + + + )} + {!error && type === INPUT_TYPE_PASSWORD && ( + + {passwordVisible ? ( + + ) : ( + + )} + + )} + {!error && type !== INPUT_TYPE_PASSWORD && !!RightIcon && ( + + + + )} + + + {error && {error}} + + ); +}; + +export const TextInputV2 = forwardRef(TextInputV2Component); diff --git a/packages/twenty-front/src/modules/ui/input/components/__stories__/TextInputV2.stories.tsx b/packages/twenty-front/src/modules/ui/input/components/__stories__/TextInputV2.stories.tsx new file mode 100644 index 000000000..8cd86abc2 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/components/__stories__/TextInputV2.stories.tsx @@ -0,0 +1,42 @@ +import { useState } from 'react'; +import { Meta, StoryObj } from '@storybook/react'; +import { ComponentDecorator } from 'twenty-ui'; + +import { + TextInputV2, + TextInputV2ComponentProps, +} from '@/ui/input/components/TextInputV2'; + +type RenderProps = TextInputV2ComponentProps; + +const Render = (args: RenderProps) => { + const [value, setValue] = useState(args.value); + const handleChange = (text: string) => { + args.onChange?.(text); + setValue(text); + }; + + // eslint-disable-next-line react/jsx-props-no-spreading + return ; +}; + +const meta: Meta = { + title: 'UI/Input/TextInputV2', + component: TextInputV2, + decorators: [ComponentDecorator], + args: { placeholder: 'Tim' }, + render: Render, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Filled: Story = { + args: { value: 'Tim' }, +}; + +export const Disabled: Story = { + args: { disabled: true, value: 'Tim' }, +}; diff --git a/packages/twenty-front/src/pages/auth/CreateProfile.tsx b/packages/twenty-front/src/pages/auth/CreateProfile.tsx index a5e76d433..a66f6994c 100644 --- a/packages/twenty-front/src/pages/auth/CreateProfile.tsx +++ b/packages/twenty-front/src/pages/auth/CreateProfile.tsx @@ -1,8 +1,9 @@ -import { useCallback } from 'react'; +import { useCallback, useState } from 'react'; import { Controller, SubmitHandler, useForm } from 'react-hook-form'; import styled from '@emotion/styled'; import { zodResolver } from '@hookform/resolvers/zod'; import { useRecoilState } from 'recoil'; +import { Key } from 'ts-key-enum'; import { z } from 'zod'; import { SubTitle } from '@/auth/components/SubTitle'; @@ -13,10 +14,12 @@ import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { ProfilePictureUploader } from '@/settings/profile/components/ProfilePictureUploader'; +import { PageHotkeyScope } from '@/types/PageHotkeyScope'; import { H2Title } from '@/ui/display/typography/components/H2Title'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { MainButton } from '@/ui/input/button/components/MainButton'; -import { TextInput } from '@/ui/input/components/TextInput'; +import { TextInputV2 } from '@/ui/input/components/TextInputV2'; +import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember'; const StyledContentContainer = styled.div` @@ -123,17 +126,22 @@ export const CreateProfile = () => { ], ); + const [isEditingMode, setIsEditingMode] = useState(false); + + useScopedHotkeys( + Key.Enter, + () => { + if (isEditingMode) { + onSubmit(getValues()); + } + }, + PageHotkeyScope.CreateProfile, + ); + if (onboardingStatus !== OnboardingStatus.OngoingProfileCreation) { return null; } - const onNameInputKeyDown = (event: React.KeyboardEvent) => { - if (event.key === 'Enter') { - event.preventDefault(); - onSubmit(getValues()); - } - }; - return ( <> Create profile @@ -157,17 +165,19 @@ export const CreateProfile = () => { field: { onChange, onBlur, value }, fieldState: { error }, }) => ( - setIsEditingMode(true)} + onBlur={() => { + onBlur(); + setIsEditingMode(false); + }} onChange={onChange} placeholder="Tim" error={error?.message} fullWidth - onKeyDown={onNameInputKeyDown} - disableHotkeys /> )} /> @@ -178,16 +188,18 @@ export const CreateProfile = () => { field: { onChange, onBlur, value }, fieldState: { error }, }) => ( - setIsEditingMode(true)} + onBlur={() => { + onBlur(); + setIsEditingMode(false); + }} onChange={onChange} placeholder="Cook" error={error?.message} fullWidth - onKeyDown={onNameInputKeyDown} - disableHotkeys /> )} /> diff --git a/packages/twenty-front/src/pages/auth/CreateWorkspace.tsx b/packages/twenty-front/src/pages/auth/CreateWorkspace.tsx index 3313ace6b..81c93bcfe 100644 --- a/packages/twenty-front/src/pages/auth/CreateWorkspace.tsx +++ b/packages/twenty-front/src/pages/auth/CreateWorkspace.tsx @@ -20,7 +20,7 @@ import { H2Title } from '@/ui/display/typography/components/H2Title'; import { Loader } from '@/ui/feedback/loader/components/Loader.tsx'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { MainButton } from '@/ui/input/button/components/MainButton'; -import { TextInput } from '@/ui/input/components/TextInput'; +import { TextInputV2 } from '@/ui/input/components/TextInputV2'; import { useActivateWorkspaceMutation } from '~/generated/graphql'; import { isDefined } from '~/utils/isDefined'; @@ -141,7 +141,7 @@ export const CreateWorkspace = () => { field: { onChange, onBlur, value }, fieldState: { error }, }) => ( - { error={error?.message} onKeyDown={handleKeyDown} fullWidth - disableHotkeys /> )} /> diff --git a/packages/twenty-front/src/pages/auth/PasswordReset.tsx b/packages/twenty-front/src/pages/auth/PasswordReset.tsx index 9de60cc46..332fb5a07 100644 --- a/packages/twenty-front/src/pages/auth/PasswordReset.tsx +++ b/packages/twenty-front/src/pages/auth/PasswordReset.tsx @@ -18,7 +18,7 @@ import { PASSWORD_REGEX } from '@/auth/utils/passwordRegex'; import { AppPath } from '@/types/AppPath'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { MainButton } from '@/ui/input/button/components/MainButton'; -import { TextInput } from '@/ui/input/components/TextInput'; +import { TextInputV2 } from '@/ui/input/components/TextInputV2'; import { AnimatedEaseIn } from '@/ui/utilities/animation/components/AnimatedEaseIn'; import { useUpdatePasswordViaResetTokenMutation, @@ -191,12 +191,11 @@ export const PasswordReset = () => { }} > - @@ -218,7 +217,7 @@ export const PasswordReset = () => { fieldState: { error }, }) => ( - { onChange={onChange} error={error?.message} fullWidth - disableHotkeys /> )}