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,135 @@
import { useUpdateCommandMenuPageInfo } from '@/command-menu/hooks/useUpdateCommandMenuPageInfo';
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { IconArrowDown, IconDotsVertical } from 'twenty-ui';
const mockedPageInfo = {
title: 'Initial Title',
Icon: IconDotsVertical,
instanceId: 'test-instance',
};
const mockedNavigationStack = [
{
page: CommandMenuPages.Root,
pageTitle: 'Initial Title',
pageIcon: IconDotsVertical,
pageId: 'test-page-id',
},
];
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<RecoilRoot
initializeState={({ set }) => {
set(commandMenuNavigationStackState, mockedNavigationStack);
set(commandMenuPageInfoState, mockedPageInfo);
}}
>
{children}
</RecoilRoot>
);
describe('useUpdateCommandMenuPageInfo', () => {
const renderHooks = () => {
const { result } = renderHook(
() => {
const { updateCommandMenuPageInfo } = useUpdateCommandMenuPageInfo();
const commandMenuNavigationStack = useRecoilValue(
commandMenuNavigationStackState,
);
const commandMenuPageInfo = useRecoilValue(commandMenuPageInfoState);
return {
updateCommandMenuPageInfo,
commandMenuNavigationStack,
commandMenuPageInfo,
};
},
{ wrapper: Wrapper },
);
return {
result,
};
};
it('should update command menu page info with new title and icon', () => {
const { result } = renderHooks();
act(() => {
result.current.updateCommandMenuPageInfo({
pageTitle: 'New Title',
pageIcon: IconArrowDown,
});
});
expect(result.current.commandMenuNavigationStack).toEqual([
{
page: CommandMenuPages.Root,
pageTitle: 'New Title',
pageIcon: IconArrowDown,
pageId: 'test-page-id',
},
]);
expect(result.current.commandMenuPageInfo).toEqual({
title: 'New Title',
Icon: IconArrowDown,
instanceId: 'test-instance',
});
});
it('should update command menu page info with new title', () => {
const { result } = renderHooks();
act(() => {
result.current.updateCommandMenuPageInfo({
pageTitle: 'New Title',
});
});
expect(result.current.commandMenuNavigationStack).toEqual([
{
page: CommandMenuPages.Root,
pageTitle: 'New Title',
pageIcon: IconDotsVertical,
pageId: 'test-page-id',
},
]);
expect(result.current.commandMenuPageInfo).toEqual({
title: 'New Title',
Icon: IconDotsVertical,
instanceId: 'test-instance',
});
});
it('should update command menu page info with new icon', () => {
const { result } = renderHooks();
act(() => {
result.current.updateCommandMenuPageInfo({
pageIcon: IconArrowDown,
});
});
expect(result.current.commandMenuNavigationStack).toEqual([
{
page: CommandMenuPages.Root,
pageTitle: 'Initial Title',
pageIcon: IconArrowDown,
pageId: 'test-page-id',
},
]);
expect(result.current.commandMenuPageInfo).toEqual({
title: 'Initial Title',
Icon: IconArrowDown,
instanceId: 'test-instance',
});
});
});

View File

@ -0,0 +1,57 @@
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
import { useRecoilCallback } from 'recoil';
import { IconComponent, IconDotsVertical } from 'twenty-ui';
export const useUpdateCommandMenuPageInfo = () => {
const updateCommandMenuPageInfo = useRecoilCallback(
({ snapshot, set }) =>
({
pageTitle,
pageIcon,
}: {
pageTitle?: string;
pageIcon?: IconComponent;
}) => {
const commandMenuPageInfo = snapshot
.getLoadable(commandMenuPageInfoState)
.getValue();
const newCommandMenuPageInfo = {
...commandMenuPageInfo,
title: pageTitle ?? commandMenuPageInfo.title ?? '',
Icon: pageIcon ?? commandMenuPageInfo.Icon ?? IconDotsVertical,
};
set(commandMenuPageInfoState, newCommandMenuPageInfo);
const commandMenuNavigationStack = snapshot
.getLoadable(commandMenuNavigationStackState)
.getValue();
const lastCommandMenuNavigationStackItem =
commandMenuNavigationStack.at(-1);
if (!lastCommandMenuNavigationStackItem) {
return;
}
const newCommandMenuNavigationStack = [
...commandMenuNavigationStack.slice(0, -1),
{
page: lastCommandMenuNavigationStackItem.page,
pageTitle: newCommandMenuPageInfo.title,
pageIcon: newCommandMenuPageInfo.Icon,
pageId: lastCommandMenuNavigationStackItem.pageId,
},
];
set(commandMenuNavigationStackState, newCommandMenuNavigationStack);
},
[],
);
return {
updateCommandMenuPageInfo,
};
};

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' },
};

View File

@ -1,10 +1,9 @@
import { TextInput } from '@/ui/field/input/components/TextInput';
import { useUpdateCommandMenuPageInfo } from '@/command-menu/hooks/useUpdateCommandMenuPageInfo';
import { TitleInput } from '@/ui/input/components/TitleInput';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useState } from 'react';
import { IconComponent } from 'twenty-ui';
import { useDebouncedCallback } from 'use-debounce';
const StyledHeader = styled.div`
background-color: ${({ theme }) => theme.background.secondary};
border-bottom: 1px solid ${({ theme }) => theme.border.color.medium};
@ -17,6 +16,7 @@ const StyledHeader = styled.div`
const StyledHeaderInfo = styled.div`
display: flex;
flex-direction: column;
width: 100%;
gap: ${({ theme }) => theme.spacing(2)};
`;
@ -24,9 +24,8 @@ const StyledHeaderTitle = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
font-size: ${({ theme }) => theme.font.size.xl};
width: 420px;
overflow: hidden;
width: fit-content;
max-width: 420px;
& > input:disabled {
color: ${({ theme }) => theme.font.color.primary};
}
@ -34,7 +33,7 @@ const StyledHeaderTitle = styled.div`
const StyledHeaderType = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
padding-left: ${({ theme }) => theme.spacing(2)};
padding-left: ${({ theme }) => theme.spacing(1)};
`;
const StyledHeaderIconContainer = styled.div`
@ -75,13 +74,18 @@ export const WorkflowStepHeader = ({
const [title, setTitle] = useState(initialTitle);
const debouncedOnTitleChange = useDebouncedCallback((newTitle: string) => {
onTitleChange?.(newTitle);
}, 100);
const { updateCommandMenuPageInfo } = useUpdateCommandMenuPageInfo();
const handleChange = (newTitle: string) => {
setTitle(newTitle);
debouncedOnTitleChange(newTitle);
};
const saveTitle = () => {
onTitleChange?.(title);
updateCommandMenuPageInfo({
pageTitle: title,
pageIcon: Icon,
});
};
return (
@ -95,15 +99,20 @@ export const WorkflowStepHeader = ({
</StyledHeaderIconContainer>
<StyledHeaderInfo>
<StyledHeaderTitle>
<TextInput
<TitleInput
disabled={disabled}
sizeVariant="md"
value={title}
copyButton={false}
hotkeyScope="workflow-step-title"
onEnter={onTitleChange}
onEscape={onTitleChange}
onChange={handleChange}
shouldTrim={false}
placeholder={headerType}
hotkeyScope="workflow-step-title"
onEnter={saveTitle}
onEscape={() => {
setTitle(initialTitle);
}}
onClickOutside={saveTitle}
onTab={saveTitle}
onShiftTab={saveTitle}
/>
</StyledHeaderTitle>
<StyledHeaderType>{headerType}</StyledHeaderType>

View File

@ -27,7 +27,8 @@ export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByDisplayValue('Create Record')).toBeVisible();
// TitleInput shows text in a div when not being edited
expect(await canvas.findByText('Create Record')).toBeVisible();
expect(await canvas.findByText('Action')).toBeVisible();
},
};
@ -43,24 +44,25 @@ export const EditableTitle: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
// First find the div with the text, then click it to activate the input
const titleText = await canvas.findByText('Create Record');
await userEvent.click(titleText);
// Now find the input that appears after clicking
const titleInput = await canvas.findByDisplayValue('Create Record');
const NEW_TITLE = 'New Title';
await userEvent.clear(titleInput);
await waitFor(() => {
expect(args.onTitleChange).toHaveBeenCalledWith('');
});
await userEvent.type(titleInput, NEW_TITLE);
// Press Enter to submit the edit
await userEvent.keyboard('{Enter}');
// Wait for the callback to be called
await waitFor(() => {
expect(args.onTitleChange).toHaveBeenCalledWith(NEW_TITLE);
});
expect(args.onTitleChange).toHaveBeenCalledTimes(2);
expect(titleInput).toHaveValue(NEW_TITLE);
},
};
@ -76,14 +78,20 @@ export const Disabled: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const titleInput = await canvas.findByDisplayValue('Create Record');
expect(titleInput).toBeDisabled();
// When disabled, TitleInput just shows text in a div, not an input
const titleText = await canvas.findByText('Create Record');
const NEW_TITLE = 'New Title';
// Check if the element has the disabled styling (cursor: default)
expect(window.getComputedStyle(titleText).cursor).toBe('default');
await userEvent.type(titleInput, NEW_TITLE);
// Try to click it - nothing should happen
await userEvent.click(titleText);
// Confirm there is no input field
const titleInput = canvas.queryByDisplayValue('Create Record');
expect(titleInput).not.toBeInTheDocument();
// Confirm the callback is not called
expect(args.onTitleChange).not.toHaveBeenCalled();
expect(titleInput).toHaveValue('Create Record');
},
};

View File

@ -73,9 +73,14 @@ export const Disabled: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const titleInput = await canvas.findByDisplayValue('Create Record');
const titleText = await canvas.findByText('Create Record');
expect(titleInput).toBeDisabled();
expect(window.getComputedStyle(titleText).cursor).toBe('default');
await userEvent.click(titleText);
const titleInput = canvas.queryByDisplayValue('Create Record');
expect(titleInput).not.toBeInTheDocument();
const objectSelectCurrentValue = await canvas.findByText('People');

View File

@ -77,9 +77,14 @@ export const DisabledWithEmptyValues: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const titleInput = await canvas.findByDisplayValue('Delete Record');
const titleText = await canvas.findByText('Delete Record');
expect(titleInput).toBeDisabled();
expect(window.getComputedStyle(titleText).cursor).toBe('default');
await userEvent.click(titleText);
const titleInput = canvas.queryByDisplayValue('Delete Record');
expect(titleInput).not.toBeInTheDocument();
const objectSelectCurrentValue = await canvas.findByText('People');
@ -123,9 +128,14 @@ export const DisabledWithDefaultStaticValues: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const titleInput = await canvas.findByDisplayValue('Delete Record');
const titleText = await canvas.findByText('Delete Record');
expect(titleInput).toBeDisabled();
expect(window.getComputedStyle(titleText).cursor).toBe('default');
await userEvent.click(titleText);
const titleInput = canvas.queryByDisplayValue('Delete Record');
expect(titleInput).not.toBeInTheDocument();
const objectSelectCurrentValue = await canvas.findByText('People');
@ -173,9 +183,14 @@ export const DisabledWithDefaultVariableValues: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const titleInput = await canvas.findByDisplayValue('Delete Record');
const titleText = await canvas.findByText('Delete Record');
expect(titleInput).toBeDisabled();
expect(window.getComputedStyle(titleText).cursor).toBe('default');
await userEvent.click(titleText);
const titleInput = canvas.queryByDisplayValue('Delete Record');
expect(titleInput).not.toBeInTheDocument();
const objectSelectCurrentValue = await canvas.findByText('People');

View File

@ -76,9 +76,14 @@ export const DisabledWithEmptyValues: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const titleInput = await canvas.findByDisplayValue('Search Records');
const titleText = await canvas.findByText('Search Records');
expect(titleInput).toBeDisabled();
expect(window.getComputedStyle(titleText).cursor).toBe('default');
await userEvent.click(titleText);
const titleInput = canvas.queryByDisplayValue('Search Records');
expect(titleInput).not.toBeInTheDocument();
const objectSelectCurrentValue = await canvas.findByText('People');

View File

@ -90,9 +90,14 @@ export const DisabledWithEmptyValues: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const titleInput = await canvas.findByDisplayValue('Update Record');
const titleText = await canvas.findByText('Update Record');
expect(titleInput).toBeDisabled();
expect(window.getComputedStyle(titleText).cursor).toBe('default');
await userEvent.click(titleText);
const titleInput = canvas.queryByDisplayValue('Update Record');
expect(titleInput).not.toBeInTheDocument();
const objectSelectCurrentValue = await canvas.findByText('People');
@ -151,9 +156,14 @@ export const DisabledWithDefaultStaticValues: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const titleInput = await canvas.findByDisplayValue('Update Record');
const titleText = await canvas.findByText('Update Record');
expect(titleInput).toBeDisabled();
expect(window.getComputedStyle(titleText).cursor).toBe('default');
await userEvent.click(titleText);
const titleInput = canvas.queryByDisplayValue('Update Record');
expect(titleInput).not.toBeInTheDocument();
const objectSelectCurrentValue = await canvas.findByText('People');
@ -214,9 +224,14 @@ export const DisabledWithDefaultVariableValues: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const titleInput = await canvas.findByDisplayValue('Update Record');
const titleText = await canvas.findByText('Update Record');
expect(titleInput).toBeDisabled();
expect(window.getComputedStyle(titleText).cursor).toBe('default');
await userEvent.click(titleText);
const titleInput = canvas.queryByDisplayValue('Update Record');
expect(titleInput).not.toBeInTheDocument();
const objectSelectCurrentValue = await canvas.findByText('People');

View File

@ -2,12 +2,13 @@ import { WorkflowFormAction } from '@/workflow/types/Workflow';
import { WorkflowEditActionFormBuilder } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder';
import { Meta, StoryObj } from '@storybook/react';
import { expect, fn, within } from '@storybook/test';
import { userEvent } from '@storybook/testing-library';
import { FieldMetadataType } from 'twenty-shared/types';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow';
import { FieldMetadataType } from 'twenty-shared/types';
const DEFAULT_ACTION = {
id: getWorkflowNodeIdMock(),
@ -89,9 +90,14 @@ export const DisabledWithEmptyValues: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const titleInput = await canvas.findByDisplayValue('Form');
const titleText = await canvas.findByText('Form');
expect(titleInput).toBeDisabled();
expect(window.getComputedStyle(titleText).cursor).toBe('default');
await userEvent.click(titleText);
const titleInput = canvas.queryByDisplayValue('Form');
expect(titleInput).not.toBeInTheDocument();
await canvas.findByText('Company');