[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 <bordeau.lucas@gmail.com>
This commit is contained in:
Marie
2024-04-22 11:19:41 +02:00
committed by GitHub
parent 3e8d42f2ed
commit 68662fa543
7 changed files with 284 additions and 214 deletions

View File

@ -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 (
<StyledAddressContainer ref={wrapperRef}>
<TextInput
<TextInputV2
autoFocus
value={internalValue.addressStreet1 ?? ''}
ref={inputRefs['addressStreet1']}
@ -187,46 +187,41 @@ export const AddressInput = ({
fullWidth
onChange={getChangeHandler('addressStreet1')}
onFocus={getFocusHandler('addressStreet1')}
disableHotkeys
/>
<TextInput
<TextInputV2
value={internalValue.addressStreet2 ?? ''}
ref={inputRefs['addressStreet2']}
label="ADDRESS 2"
fullWidth
onChange={getChangeHandler('addressStreet2')}
onFocus={getFocusHandler('addressStreet2')}
disableHotkeys
/>
<StyledHalfRowContainer>
<TextInput
<TextInputV2
value={internalValue.addressCity ?? ''}
ref={inputRefs['addressCity']}
label="CITY"
fullWidth
onChange={getChangeHandler('addressCity')}
onFocus={getFocusHandler('addressCity')}
disableHotkeys
/>
<TextInput
<TextInputV2
value={internalValue.addressState ?? ''}
ref={inputRefs['addressState']}
label="STATE"
fullWidth
onChange={getChangeHandler('addressState')}
onFocus={getFocusHandler('addressState')}
disableHotkeys
/>
</StyledHalfRowContainer>
<StyledHalfRowContainer>
<TextInput
<TextInputV2
value={internalValue.addressPostcode ?? ''}
ref={inputRefs['addressPostcode']}
label="POST CODE"
fullWidth
onChange={getChangeHandler('addressPostcode')}
onFocus={getFocusHandler('addressPostcode')}
disableHotkeys
/>
<CountrySelect
onChange={getChangeHandler('addressCountry')}

View File

@ -1,141 +1,26 @@
import {
ChangeEvent,
FocusEventHandler,
ForwardedRef,
forwardRef,
InputHTMLAttributes,
useRef,
useState,
} from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { FocusEventHandler, forwardRef, useRef } from 'react';
import { Key } from 'ts-key-enum';
import { IconAlertCircle, IconComponent, IconEye, IconEyeOff } from 'twenty-ui';
import {
TextInputV2,
TextInputV2ComponentProps,
} from '@/ui/input/components/TextInputV2';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useCombinedRefs } from '~/hooks/useCombinedRefs';
import { InputHotkeyScope } from '../types/InputHotkeyScope';
const StyledContainer = styled.div<Pick<TextInputComponentProps, 'fullWidth'>>`
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<Pick<TextInputComponentProps, 'fullWidth'>>`
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<HTMLInputElement>,
'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<HTMLInputElement>) => 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<HTMLInputElement>,
): JSX.Element => {
const theme = useTheme();
const TextInputComponent = ({
onFocus,
onBlur,
disableHotkeys = false,
...props
}: TextInputComponentProps): JSX.Element => {
const inputRef = useRef<HTMLInputElement>(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 (
<StyledContainer className={className} fullWidth={fullWidth ?? false}>
{label && <StyledLabel>{label + (required ? '*' : '')}</StyledLabel>}
<StyledInputContainer>
<StyledInput
autoComplete="off"
ref={combinedRef}
tabIndex={tabIndex ?? 0}
onFocus={handleFocus}
onBlur={handleBlur}
type={passwordVisible ? 'text' : type}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
onChange?.(event.target.value);
}}
onKeyDown={onKeyDown}
{...{ autoFocus, disabled, placeholder, required, value }}
/>
<StyledTrailingIconContainer>
{error && (
<StyledTrailingIcon>
<IconAlertCircle size={16} color={theme.color.red} />
</StyledTrailingIcon>
)}
{!error && type === INPUT_TYPE_PASSWORD && (
<StyledTrailingIcon
onClick={handleTogglePasswordVisibility}
data-testid="reveal-password-button"
>
{passwordVisible ? (
<IconEyeOff size={theme.icon.size.md} />
) : (
<IconEye size={theme.icon.size.md} />
)}
</StyledTrailingIcon>
)}
{!error && type !== INPUT_TYPE_PASSWORD && !!RightIcon && (
<StyledTrailingIcon>
<RightIcon size={theme.icon.size.md} />
</StyledTrailingIcon>
)}
</StyledTrailingIconContainer>
</StyledInputContainer>
{error && <StyledErrorHelper>{error}</StyledErrorHelper>}
</StyledContainer>
);
// eslint-disable-next-line react/jsx-props-no-spreading
return <TextInputV2 {...props} onFocus={handleFocus} onBlur={handleBlur} />;
};
export const TextInput = forwardRef(TextInputComponent);

View File

@ -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<TextInputV2ComponentProps, 'fullWidth'>
>`
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<Pick<TextInputV2ComponentProps, 'fullWidth'>>`
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<HTMLInputElement>,
'onChange' | 'onKeyDown'
> & {
className?: string;
label?: string;
onChange?: (text: string) => void;
fullWidth?: boolean;
error?: string;
RightIcon?: IconComponent;
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
onBlur?: FocusEventHandler<HTMLInputElement>;
};
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<HTMLInputElement>,
): JSX.Element => {
const theme = useTheme();
const inputRef = useRef<HTMLInputElement>(null);
const combinedRef = useCombinedRefs(ref, inputRef);
const [passwordVisible, setPasswordVisible] = useState(false);
const handleTogglePasswordVisibility = () => {
setPasswordVisible(!passwordVisible);
};
return (
<StyledContainer className={className} fullWidth={fullWidth ?? false}>
{label && <StyledLabel>{label + (required ? '*' : '')}</StyledLabel>}
<StyledInputContainer>
<StyledInput
autoComplete="off"
ref={combinedRef}
tabIndex={tabIndex ?? 0}
onFocus={onFocus}
onBlur={onBlur}
type={passwordVisible ? 'text' : type}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
onChange?.(event.target.value);
}}
onKeyDown={onKeyDown}
{...{ autoFocus, disabled, placeholder, required, value }}
/>
<StyledTrailingIconContainer>
{error && (
<StyledTrailingIcon>
<IconAlertCircle size={16} color={theme.color.red} />
</StyledTrailingIcon>
)}
{!error && type === INPUT_TYPE_PASSWORD && (
<StyledTrailingIcon
onClick={handleTogglePasswordVisibility}
data-testid="reveal-password-button"
>
{passwordVisible ? (
<IconEyeOff size={theme.icon.size.md} />
) : (
<IconEye size={theme.icon.size.md} />
)}
</StyledTrailingIcon>
)}
{!error && type !== INPUT_TYPE_PASSWORD && !!RightIcon && (
<StyledTrailingIcon>
<RightIcon size={theme.icon.size.md} />
</StyledTrailingIcon>
)}
</StyledTrailingIconContainer>
</StyledInputContainer>
{error && <StyledErrorHelper>{error}</StyledErrorHelper>}
</StyledContainer>
);
};
export const TextInputV2 = forwardRef(TextInputV2Component);

View File

@ -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 <TextInputV2 {...args} value={value} onChange={handleChange} />;
};
const meta: Meta<typeof TextInputV2> = {
title: 'UI/Input/TextInputV2',
component: TextInputV2,
decorators: [ComponentDecorator],
args: { placeholder: 'Tim' },
render: Render,
};
export default meta;
type Story = StoryObj<typeof TextInputV2>;
export const Default: Story = {};
export const Filled: Story = {
args: { value: 'Tim' },
};
export const Disabled: Story = {
args: { disabled: true, value: 'Tim' },
};

View File

@ -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<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault();
onSubmit(getValues());
}
};
return (
<>
<Title withMarginTop={false}>Create profile</Title>
@ -157,17 +165,19 @@ export const CreateProfile = () => {
field: { onChange, onBlur, value },
fieldState: { error },
}) => (
<TextInput
<TextInputV2
autoFocus
label="First Name"
value={value}
onBlur={onBlur}
onFocus={() => 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 },
}) => (
<TextInput
<TextInputV2
label="Last Name"
value={value}
onBlur={onBlur}
onFocus={() => setIsEditingMode(true)}
onBlur={() => {
onBlur();
setIsEditingMode(false);
}}
onChange={onChange}
placeholder="Cook"
error={error?.message}
fullWidth
onKeyDown={onNameInputKeyDown}
disableHotkeys
/>
)}
/>

View File

@ -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 },
}) => (
<TextInput
<TextInputV2
autoFocus
value={value}
placeholder="Apple"
@ -150,7 +150,6 @@ export const CreateWorkspace = () => {
error={error?.message}
onKeyDown={handleKeyDown}
fullWidth
disableHotkeys
/>
)}
/>

View File

@ -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 = () => {
}}
>
<StyledInputContainer>
<TextInput
<TextInputV2
autoFocus
value={email}
placeholder="Email"
fullWidth
disableHotkeys
disabled
/>
</StyledInputContainer>
@ -218,7 +217,7 @@ export const PasswordReset = () => {
fieldState: { error },
}) => (
<StyledInputContainer>
<TextInput
<TextInputV2
autoFocus
value={value}
type="password"
@ -227,7 +226,6 @@ export const PasswordReset = () => {
onChange={onChange}
error={error?.message}
fullWidth
disableHotkeys
/>
</StyledInputContainer>
)}