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

@ -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');