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:
@ -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>
|
||||
|
||||
@ -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');
|
||||
},
|
||||
};
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user