New TitleInput UI component for side panel (#11192)

# Description

I previously introduced the `RecordTitleCell` component, but it was
coupled with the field context, so it was only usable for record fields.
This PR:
- Introduces a new component `TitleInput` for side panel pages which
needed to have an editable title which wasn't a record field.
- Fixes the hotkey scope problem with the workflow step page title
- Introduces a new hook `useUpdateCommandMenuPageInfo`, to update the
side panel page title and icon.
- Fixes workflow side panel UI
- Adds jest tests and stories

# Video



https://github.com/user-attachments/assets/c501245c-4492-4351-b761-05b5abc4bd14
This commit is contained in:
Raphaël Bosi
2025-03-26 17:31:48 +01:00
committed by GitHub
parent 4827ad600d
commit 3660bec01d
11 changed files with 551 additions and 50 deletions

View File

@ -0,0 +1,182 @@
import {
TextInputV2,
TextInputV2Size,
} from '@/ui/input/components/TextInputV2';
import { useRef, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useRegisterInputEvents } from '@/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import styled from '@emotion/styled';
import { OverflowingTextWithTooltip } from 'twenty-ui';
type InputProps = {
value?: string;
onChange: (value: string) => void;
placeholder?: string;
hotkeyScope?: string;
onEnter?: () => void;
onEscape?: () => void;
onClickOutside?: () => void;
onTab?: () => void;
onShiftTab?: () => void;
sizeVariant?: TextInputV2Size;
};
export type TitleInputProps = {
disabled?: boolean;
} & InputProps;
const StyledDiv = styled.div<{
sizeVariant: TextInputV2Size;
disabled?: boolean;
}>`
background: inherit;
border: none;
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.font.color.primary};
cursor: ${({ disabled }) => (disabled ? 'default' : 'pointer')};
overflow: hidden;
height: ${({ sizeVariant }) =>
sizeVariant === 'xs'
? '20px'
: sizeVariant === 'sm'
? '24px'
: sizeVariant === 'md'
? '28px'
: '32px'};
padding: ${({ theme }) => theme.spacing(0, 1.25)};
box-sizing: border-box;
display: flex;
align-items: center;
:hover {
background: ${({ theme, disabled }) =>
disabled ? 'inherit' : theme.background.transparent.light};
}
`;
const Input = ({
value,
onChange,
placeholder,
hotkeyScope = 'title-input',
onEnter,
onEscape,
onClickOutside,
onTab,
onShiftTab,
setIsOpened,
sizeVariant,
}: InputProps & { setIsOpened: (isOpened: boolean) => void }) => {
const wrapperRef = useRef<HTMLInputElement>(null);
const [draftValue, setDraftValue] = useState(value ?? '');
const handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
if (isDefined(value)) {
event.target.select();
}
};
const { goBackToPreviousHotkeyScope } = usePreviousHotkeyScope();
const handleLeaveFocus = () => {
setIsOpened(false);
goBackToPreviousHotkeyScope();
};
useRegisterInputEvents<string>({
inputRef: wrapperRef,
inputValue: draftValue,
onEnter: () => {
handleLeaveFocus();
onEnter?.();
},
onEscape: () => {
handleLeaveFocus();
onEscape?.();
},
onClickOutside: (event) => {
event.stopImmediatePropagation();
handleLeaveFocus();
onClickOutside?.();
},
onTab: () => {
handleLeaveFocus();
onTab?.();
},
onShiftTab: () => {
handleLeaveFocus();
onShiftTab?.();
},
hotkeyScope: hotkeyScope,
});
return (
<TextInputV2
ref={wrapperRef}
autoGrow
sizeVariant={sizeVariant}
inheritFontStyles
value={draftValue}
onChange={(text) => {
setDraftValue(text);
onChange?.(text);
}}
placeholder={placeholder}
onFocus={handleFocus}
autoFocus
/>
);
};
export const TitleInput = ({
disabled,
value,
sizeVariant = 'md',
onChange,
placeholder,
hotkeyScope = 'title-input',
onEnter,
onEscape,
onClickOutside,
onTab,
onShiftTab,
}: TitleInputProps) => {
const [isOpened, setIsOpened] = useState(false);
const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope();
return (
<>
{isOpened ? (
<Input
sizeVariant={sizeVariant}
value={value}
onChange={onChange}
placeholder={placeholder}
hotkeyScope={hotkeyScope}
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
setIsOpened={setIsOpened}
/>
) : (
<StyledDiv
sizeVariant={sizeVariant}
disabled={disabled}
onClick={() => {
if (!disabled) {
setIsOpened(true);
setHotkeyScopeAndMemorizePreviousScope(hotkeyScope);
}
}}
>
<OverflowingTextWithTooltip text={value || placeholder} />
</StyledDiv>
)}
</>
);
};

View File

@ -0,0 +1,64 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui';
import { TitleInput } from '@/ui/input/components/TitleInput';
const meta: Meta<typeof TitleInput> = {
title: 'UI/Input/TitleInput',
component: TitleInput,
decorators: [ComponentDecorator],
args: {
placeholder: 'Enter title',
hotkeyScope: 'titleInput',
sizeVariant: 'md',
},
argTypes: {
hotkeyScope: { control: false },
sizeVariant: { control: false },
},
};
export default meta;
type Story = StoryObj<typeof TitleInput>;
export const Default: Story = {};
export const WithValue: Story = {
args: { value: 'Sample Title' },
};
export const Disabled: Story = {
args: { disabled: true, value: 'Disabled Title' },
};
export const ExtraSmall: Story = {
args: { sizeVariant: 'xs', value: 'Extra Small Title' },
};
export const Small: Story = {
args: { sizeVariant: 'sm', value: 'Small Title' },
};
export const Medium: Story = {
args: { sizeVariant: 'md', value: 'Medium Title' },
};
export const Large: Story = {
args: { sizeVariant: 'lg', value: 'Large Title' },
};
export const WithLongText: Story = {
args: {
value:
'This is a very long title that will likely overflow and demonstrate the tooltip behavior of the component',
},
parameters: {
container: {
width: 250,
},
},
};
export const WithCustomPlaceholder: Story = {
args: { placeholder: 'Custom placeholder example' },
};