384 update the input of the record show page inside the command menu (#10213)

Created a new component `RecordTitleCell` with an API close to
`RecordInlineCell`.
This new component is an autogrowing input. 
It consumes the `FieldContext`. It uses some hooks and states from
`RecordInlineCell` because I didn't want to duplicate all the logic, but
this logic could be duplicated.

Two issues that I didn't solve in this PR:
- There is a flashing glitch inside the input when typing
- The input of a workflow isn't focused when creating a new one. This is
because of an issue with the `useHotkeyScopeOnMount` hook which is
deprecated but still used in some components. Upon redirection on the
workflow showpage, the hokey scope of the input is overridden by the
hokey scopes of the components which use `useHotkeyScopeOnMount`. I
decided not to open the input for now.

## Command menu record show page

### Single input


https://github.com/user-attachments/assets/50dc235c-8f34-4445-8b04-586125606bd5

### Double input


https://github.com/user-attachments/assets/bdcfd6eb-d25e-4006-a87f-6e615e8a6e7e

## Workflow breadcrumb


https://github.com/user-attachments/assets/ded38dd6-5794-4779-a4ae-b3948567595a

## Record show page

### Single input


https://github.com/user-attachments/assets/8ad7a606-556a-416b-8788-93415f7989e1

### Double input


https://github.com/user-attachments/assets/55aae40b-36ae-40f1-8171-06f1a5db3532
This commit is contained in:
Raphaël Bosi
2025-02-14 13:33:18 +01:00
committed by GitHub
parent a4a085392d
commit 80c55b4462
85 changed files with 1415 additions and 757 deletions

View File

@ -1,10 +1,10 @@
import { InputErrorHelper } from '@/ui/input/components/InputErrorHelper';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
ChangeEvent,
FocusEventHandler,
ForwardedRef,
InputHTMLAttributes,
forwardRef,
useId,
@ -19,7 +19,6 @@ import {
} from 'twenty-ui';
import { useCombinedRefs } from '~/hooks/useCombinedRefs';
import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly';
import { InputErrorHelper } from '@/ui/input/components/InputErrorHelper';
const StyledContainer = styled.div<
Pick<TextInputV2ComponentProps, 'fullWidth'>
@ -40,7 +39,12 @@ const StyledInputContainer = styled.div`
const StyledInput = styled.input<
Pick<
TextInputV2ComponentProps,
'LeftIcon' | 'error' | 'sizeVariant' | 'width'
| 'LeftIcon'
| 'error'
| 'sizeVariant'
| 'width'
| 'inheritFontStyles'
| 'autoGrow'
>
>`
background-color: ${({ theme }) => theme.background.transparent.lighter};
@ -52,17 +56,30 @@ const StyledInput = styled.input<
color: ${({ theme }) => theme.font.color.primary};
display: flex;
flex-grow: 1;
font-family: ${({ theme }) => theme.font.family};
font-weight: ${({ theme }) => theme.font.weight.regular};
font-family: ${({ theme, inheritFontStyles }) =>
inheritFontStyles ? 'inherit' : theme.font.family};
font-size: ${({ theme, inheritFontStyles }) =>
inheritFontStyles ? 'inherit' : theme.font.size.md};
font-weight: ${({ theme, inheritFontStyles }) =>
inheritFontStyles ? 'inherit' : theme.font.weight.regular};
height: ${({ sizeVariant }) =>
sizeVariant === 'sm' ? '20px' : sizeVariant === 'md' ? '28px' : '32px'};
outline: none;
padding: ${({ theme, sizeVariant }) =>
sizeVariant === 'sm' ? `${theme.spacing(2)} 0` : theme.spacing(2)};
padding-left: ${({ theme, LeftIcon }) =>
LeftIcon ? `calc(${theme.spacing(3)} + 16px)` : theme.spacing(2)};
padding: ${({ theme, sizeVariant, autoGrow }) =>
autoGrow
? theme.spacing(1)
: sizeVariant === 'sm'
? `${theme.spacing(2)} 0`
: theme.spacing(2)};
padding-left: ${({ theme, LeftIcon, autoGrow }) =>
autoGrow
? theme.spacing(1)
: LeftIcon
? `calc(${theme.spacing(3)} + 16px)`
: theme.spacing(2)};
width: ${({ theme, width }) =>
width ? `calc(${width}px + ${theme.spacing(5)})` : '100%'};
width ? `calc(${width}px + ${theme.spacing(0.5)})` : '100%'};
max-width: ${({ autoGrow }) => (autoGrow ? '100%' : 'none')};
&::placeholder,
&::-webkit-input-placeholder {
@ -144,149 +161,180 @@ export type TextInputV2ComponentProps = Omit<
onBlur?: FocusEventHandler<HTMLInputElement>;
dataTestId?: string;
sizeVariant?: TextInputV2Size;
inheritFontStyles?: boolean;
};
type TextInputV2WithAutoGrowWrapperProps = TextInputV2ComponentProps;
const TextInputV2Component = (
{
className,
label,
value,
onChange,
onFocus,
onBlur,
onKeyDown,
fullWidth,
width,
error,
noErrorHelper = false,
required,
type,
autoFocus,
placeholder,
disabled,
tabIndex,
RightIcon,
LeftIcon,
autoComplete,
maxLength,
sizeVariant = 'lg',
dataTestId,
}: TextInputV2ComponentProps,
// eslint-disable-next-line @nx/workspace-component-props-naming
ref: ForwardedRef<HTMLInputElement>,
): JSX.Element => {
const theme = useTheme();
const TextInputV2Component = forwardRef<
HTMLInputElement,
TextInputV2ComponentProps
>(
(
{
className,
label,
value,
onChange,
onFocus,
onBlur,
onKeyDown,
fullWidth,
width,
error,
noErrorHelper = false,
required,
type,
autoFocus,
placeholder,
disabled,
tabIndex,
RightIcon,
LeftIcon,
autoComplete,
maxLength,
sizeVariant = 'md',
inheritFontStyles = false,
dataTestId,
autoGrow = false,
},
ref,
) => {
const theme = useTheme();
const inputRef = useRef<HTMLInputElement>(null);
const combinedRef = useCombinedRefs(ref, inputRef);
const inputRef = useRef<HTMLInputElement>(null);
const combinedRef = useCombinedRefs(ref, inputRef);
const [passwordVisible, setPasswordVisible] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const [passwordVisible, setPasswordVisible] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const handleTogglePasswordVisibility = () => {
setPasswordVisible(!passwordVisible);
};
const handleTogglePasswordVisibility = () => {
setPasswordVisible(!passwordVisible);
};
const handleFocus: FocusEventHandler<HTMLInputElement> = (event) => {
setIsFocused(true);
onFocus?.(event);
};
const handleFocus: FocusEventHandler<HTMLInputElement> = (event) => {
setIsFocused(true);
onFocus?.(event);
};
const handleBlur: FocusEventHandler<HTMLInputElement> = (event) => {
setIsFocused(false);
onBlur?.(event);
};
const handleBlur: FocusEventHandler<HTMLInputElement> = (event) => {
setIsFocused(false);
onBlur?.(event);
};
const inputId = useId();
const inputId = useId();
return (
<StyledContainer className={className} fullWidth={fullWidth ?? false}>
{label && (
<InputLabel htmlFor={inputId}>
{label + (required ? '*' : '')}
</InputLabel>
)}
<StyledInputContainer>
{!!LeftIcon && (
<StyledLeftIconContainer sizeVariant={sizeVariant}>
<StyledTrailingIcon isFocused={isFocused}>
<LeftIcon size={theme.icon.size.md} />
</StyledTrailingIcon>
</StyledLeftIconContainer>
return (
<StyledContainer className={className} fullWidth={fullWidth ?? false}>
{label && (
<InputLabel htmlFor={inputId}>
{label + (required ? '*' : '')}
</InputLabel>
)}
<StyledInput
id={inputId}
width={width}
data-testid={dataTestId}
autoComplete={autoComplete || 'off'}
ref={combinedRef}
tabIndex={tabIndex ?? 0}
onFocus={handleFocus}
onBlur={handleBlur}
type={passwordVisible ? 'text' : type}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
onChange?.(
turnIntoEmptyStringIfWhitespacesOnly(event.target.value),
);
}}
onKeyDown={onKeyDown}
{...{
autoFocus,
disabled,
placeholder,
required,
value,
LeftIcon,
maxLength,
error,
sizeVariant,
}}
/>
<StyledTrailingIconContainer {...{ error }}>
{!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>
<StyledInputContainer>
{!!LeftIcon && (
<StyledLeftIconContainer sizeVariant={sizeVariant}>
<StyledTrailingIcon isFocused={isFocused}>
<LeftIcon size={theme.icon.size.md} />
</StyledTrailingIcon>
</StyledLeftIconContainer>
)}
{!error && type !== INPUT_TYPE_PASSWORD && !!RightIcon && (
<StyledTrailingIcon>
<RightIcon size={theme.icon.size.md} />
</StyledTrailingIcon>
)}
</StyledTrailingIconContainer>
</StyledInputContainer>
<InputErrorHelper isVisible={!noErrorHelper}>{error}</InputErrorHelper>
</StyledContainer>
);
};
const TextInputV2WithAutoGrowWrapper = (
props: TextInputV2WithAutoGrowWrapperProps,
) => (
<>
{props.autoGrow ? (
<ComputeNodeDimensions node={props.value || props.placeholder}>
{(nodeDimensions) => (
// eslint-disable-next-line
<TextInputV2Component {...props} width={nodeDimensions?.width} />
)}
</ComputeNodeDimensions>
) : (
// eslint-disable-next-line
<TextInputV2Component {...props} />
)}
</>
<StyledInput
id={inputId}
width={width}
data-testid={dataTestId}
autoComplete={autoComplete || 'off'}
ref={combinedRef}
tabIndex={tabIndex ?? 0}
onFocus={handleFocus}
onBlur={handleBlur}
type={passwordVisible ? 'text' : type}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
onChange?.(
turnIntoEmptyStringIfWhitespacesOnly(event.target.value),
);
}}
onKeyDown={onKeyDown}
{...{
autoFocus,
disabled,
placeholder,
required,
value,
LeftIcon,
maxLength,
error,
sizeVariant,
inheritFontStyles,
autoGrow,
}}
/>
<StyledTrailingIconContainer {...{ error }}>
{!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>
<InputErrorHelper isVisible={!noErrorHelper}>{error}</InputErrorHelper>
</StyledContainer>
);
},
);
export const TextInputV2 = forwardRef(TextInputV2WithAutoGrowWrapper);
const StyledComputeNodeDimensions = styled(ComputeNodeDimensions)<{
sizeVariant?: TextInputV2Size;
}>`
border: 1px solid transparent;
height: ${({ sizeVariant }) =>
sizeVariant === 'sm' ? '20px' : sizeVariant === 'md' ? '28px' : '32px'};
padding: 0 ${({ theme }) => theme.spacing(1)};
box-sizing: border-box;
`;
const TextInputV2WithAutoGrowWrapper = forwardRef<
HTMLInputElement,
TextInputV2WithAutoGrowWrapperProps
>((props, ref) => {
return (
<>
{props.autoGrow ? (
<StyledComputeNodeDimensions
sizeVariant={props.sizeVariant}
node={props.value || props.placeholder}
>
{(nodeDimensions) => (
<TextInputV2Component
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
ref={ref}
width={nodeDimensions?.width}
/>
)}
</StyledComputeNodeDimensions>
) : (
<TextInputV2Component
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
ref={ref}
/>
)}
</>
);
});
export const TextInputV2 = TextInputV2WithAutoGrowWrapper;

View File

@ -49,7 +49,7 @@ const StyledLeftContainer = styled.div`
gap: ${({ theme }) => theme.spacing(1)};
padding-left: ${({ theme }) => theme.spacing(1)};
overflow-x: hidden;
width: 100%;
@media (max-width: ${MOBILE_VIEWPORT}px) {
padding-left: ${({ theme }) => theme.spacing(1)};
}
@ -60,6 +60,8 @@ const StyledTitleContainer = styled.div`
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.medium};
margin-left: ${({ theme }) => theme.spacing(1)};
width: 100%;
overflow: hidden;
`;
const StyledTopBarIconStyledTitleContainer = styled.div`
@ -67,6 +69,8 @@ const StyledTopBarIconStyledTitleContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
flex-direction: row;
width: 100%;
overflow: hidden;
`;
const StyledPageActionContainer = styled.div`
@ -81,10 +85,9 @@ const StyledTopBarButtonContainer = styled.div`
`;
const StyledIconContainer = styled.div`
flex: 1 0 1;
align-items: center;
display: flex;
flex-direction: row;
align-items: center;
`;
type PageHeaderProps = {

View File

@ -62,7 +62,7 @@ const StyledTitle = styled.div<{ isMobile: boolean }>`
font-size: ${({ theme }) => theme.font.size.xl};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
justify-content: ${({ isMobile }) => (isMobile ? 'flex-start' : 'center')};
max-width: 90%;
width: 90%;
`;
const StyledAvatarWrapper = styled.div<{ isAvatarEditable: boolean }>`

View File

@ -1,119 +0,0 @@
import { isUpdatingRecordEditableNameState } from '@/object-record/states/isUpdatingRecordEditableName';
import { TextInputV2 } from '@/ui/input/components/TextInputV2';
import { useOpenEditableBreadCrumbItem } from '@/ui/navigation/bread-crumb/hooks/useOpenEditableBreadCrumbItem';
import { EditableBreadcrumbItemHotkeyScope } from '@/ui/navigation/bread-crumb/types/EditableBreadcrumbItemHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import styled from '@emotion/styled';
import { useRef, useState } from 'react';
import { useRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import { isDefined } from 'twenty-shared';
import { useHotkeyScopeOnMount } from '~/hooks/useHotkeyScopeOnMount';
type EditableBreadcrumbItemProps = {
className?: string;
defaultValue: string;
noValuePlaceholder?: string;
placeholder: string;
onSubmit: (value: string) => void;
hotkeyScope: string;
};
const StyledButton = styled('button')`
align-items: center;
background: inherit;
border: none;
border-radius: ${({ theme }) => theme.border.radius.sm};
box-sizing: content-box;
color: ${({ theme }) => theme.font.color.primary};
cursor: pointer;
display: flex;
font-family: ${({ theme }) => theme.font.family};
font-size: ${({ theme }) => theme.font.size.md};
height: 20px;
overflow: hidden;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
:hover {
background: ${({ theme }) => theme.background.transparent.light};
}
`;
export const EditableBreadcrumbItem = ({
className,
defaultValue,
noValuePlaceholder,
placeholder,
onSubmit,
}: EditableBreadcrumbItemProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const [isUpdatingRecordEditableName, setIsUpdatingRecordEditableName] =
useRecoilState(isUpdatingRecordEditableNameState);
// TODO: remove this and set the hokey scopes synchronously on page change inside the useNavigateApp hook
useHotkeyScopeOnMount(
EditableBreadcrumbItemHotkeyScope.EditableBreadcrumbItem,
);
useScopedHotkeys(
[Key.Escape],
() => {
setIsUpdatingRecordEditableName(false);
},
EditableBreadcrumbItemHotkeyScope.EditableBreadcrumbItem,
);
useScopedHotkeys(
[Key.Enter],
() => {
onSubmit(value);
setIsUpdatingRecordEditableName(false);
},
EditableBreadcrumbItemHotkeyScope.EditableBreadcrumbItem,
);
const clickOutsideRefs: Array<React.RefObject<HTMLElement>> = [
inputRef,
buttonRef,
];
useListenClickOutside({
refs: clickOutsideRefs,
callback: () => {
setIsUpdatingRecordEditableName(false);
},
listenerId: 'editable-breadcrumb-item',
});
const handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
if (isDefined(value)) {
event.target.select();
}
};
const [value, setValue] = useState<string>(defaultValue);
const { openEditableBreadCrumbItem } = useOpenEditableBreadCrumbItem();
return isUpdatingRecordEditableName ? (
<TextInputV2
className={className}
autoGrow
sizeVariant="sm"
ref={inputRef}
value={value}
onChange={setValue}
placeholder={placeholder}
onFocus={handleFocus}
autoFocus
/>
) : (
<StyledButton ref={buttonRef} onClick={openEditableBreadCrumbItem}>
{value || noValuePlaceholder}
</StyledButton>
);
};

View File

@ -1,68 +0,0 @@
import { expect, jest } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react';
import { RecoilRoot } from 'recoil';
import { EditableBreadcrumbItemHotkeyScope } from '@/ui/navigation/bread-crumb/types/EditableBreadcrumbItemHotkeyScope';
import { findByText, userEvent } from '@storybook/test';
import { ComponentDecorator } from 'twenty-ui';
import { EditableBreadcrumbItem } from '../EditableBreadcrumbItem';
const onSubmit = jest.fn();
const meta: Meta<typeof EditableBreadcrumbItem> = {
title: 'UI/Navigation/BreadCrumb/EditableBreadcrumbItem',
component: EditableBreadcrumbItem,
decorators: [
(Story) => (
<RecoilRoot>
<Story />
</RecoilRoot>
),
ComponentDecorator,
],
args: {
defaultValue: 'Company Name',
placeholder: 'Enter name',
hotkeyScope: EditableBreadcrumbItemHotkeyScope.EditableBreadcrumbItem,
onSubmit,
},
};
export default meta;
type Story = StoryObj<typeof EditableBreadcrumbItem>;
export const Default: Story = {
args: {},
play: async ({ canvasElement }) => {
const button = await findByText(canvasElement, 'Company Name');
expect(button).toBeInTheDocument();
},
};
export const Editing: Story = {
args: {},
play: async ({ canvasElement }) => {
const button = canvasElement.querySelector('button');
await userEvent.click(button);
await new Promise((resolve) => setTimeout(resolve, 100));
await userEvent.keyboard('New Name');
await userEvent.keyboard('{Enter}');
expect(onSubmit).toHaveBeenCalledWith('New Name');
},
};
export const WithNoValue: Story = {
args: {
defaultValue: '',
noValuePlaceholder: 'Untitled',
},
play: async ({ canvasElement }) => {
const button = await findByText(canvasElement, 'Untitled');
expect(button).toBeInTheDocument();
},
};

View File

@ -1,19 +0,0 @@
import { isUpdatingRecordEditableNameState } from '@/object-record/states/isUpdatingRecordEditableName';
import { EditableBreadcrumbItemHotkeyScope } from '@/ui/navigation/bread-crumb/types/EditableBreadcrumbItemHotkeyScope';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { useSetRecoilState } from 'recoil';
export const useOpenEditableBreadCrumbItem = () => {
const setIsUpdatingRecordEditableName = useSetRecoilState(
isUpdatingRecordEditableNameState,
);
const setHotkeyScope = useSetHotkeyScope();
const openEditableBreadCrumbItem = () => {
setIsUpdatingRecordEditableName(true);
setHotkeyScope(EditableBreadcrumbItemHotkeyScope.EditableBreadcrumbItem);
};
return { openEditableBreadCrumbItem };
};

View File

@ -1,3 +0,0 @@
export enum EditableBreadcrumbItemHotkeyScope {
EditableBreadcrumbItem = 'editable-breadcrumb-item',
}