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:
@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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' },
|
||||||
|
};
|
||||||
@ -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 { useTheme } from '@emotion/react';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { IconComponent } from 'twenty-ui';
|
import { IconComponent } from 'twenty-ui';
|
||||||
import { useDebouncedCallback } from 'use-debounce';
|
|
||||||
|
|
||||||
const StyledHeader = styled.div`
|
const StyledHeader = styled.div`
|
||||||
background-color: ${({ theme }) => theme.background.secondary};
|
background-color: ${({ theme }) => theme.background.secondary};
|
||||||
border-bottom: 1px solid ${({ theme }) => theme.border.color.medium};
|
border-bottom: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||||
@ -17,6 +16,7 @@ const StyledHeader = styled.div`
|
|||||||
const StyledHeaderInfo = styled.div`
|
const StyledHeaderInfo = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
gap: ${({ theme }) => theme.spacing(2)};
|
gap: ${({ theme }) => theme.spacing(2)};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@ -24,9 +24,8 @@ const StyledHeaderTitle = styled.div`
|
|||||||
color: ${({ theme }) => theme.font.color.primary};
|
color: ${({ theme }) => theme.font.color.primary};
|
||||||
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||||
font-size: ${({ theme }) => theme.font.size.xl};
|
font-size: ${({ theme }) => theme.font.size.xl};
|
||||||
width: 420px;
|
width: fit-content;
|
||||||
overflow: hidden;
|
max-width: 420px;
|
||||||
|
|
||||||
& > input:disabled {
|
& > input:disabled {
|
||||||
color: ${({ theme }) => theme.font.color.primary};
|
color: ${({ theme }) => theme.font.color.primary};
|
||||||
}
|
}
|
||||||
@ -34,7 +33,7 @@ const StyledHeaderTitle = styled.div`
|
|||||||
|
|
||||||
const StyledHeaderType = styled.div`
|
const StyledHeaderType = styled.div`
|
||||||
color: ${({ theme }) => theme.font.color.tertiary};
|
color: ${({ theme }) => theme.font.color.tertiary};
|
||||||
padding-left: ${({ theme }) => theme.spacing(2)};
|
padding-left: ${({ theme }) => theme.spacing(1)};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledHeaderIconContainer = styled.div`
|
const StyledHeaderIconContainer = styled.div`
|
||||||
@ -75,13 +74,18 @@ export const WorkflowStepHeader = ({
|
|||||||
|
|
||||||
const [title, setTitle] = useState(initialTitle);
|
const [title, setTitle] = useState(initialTitle);
|
||||||
|
|
||||||
const debouncedOnTitleChange = useDebouncedCallback((newTitle: string) => {
|
const { updateCommandMenuPageInfo } = useUpdateCommandMenuPageInfo();
|
||||||
onTitleChange?.(newTitle);
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
const handleChange = (newTitle: string) => {
|
const handleChange = (newTitle: string) => {
|
||||||
setTitle(newTitle);
|
setTitle(newTitle);
|
||||||
debouncedOnTitleChange(newTitle);
|
};
|
||||||
|
|
||||||
|
const saveTitle = () => {
|
||||||
|
onTitleChange?.(title);
|
||||||
|
updateCommandMenuPageInfo({
|
||||||
|
pageTitle: title,
|
||||||
|
pageIcon: Icon,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -95,15 +99,20 @@ export const WorkflowStepHeader = ({
|
|||||||
</StyledHeaderIconContainer>
|
</StyledHeaderIconContainer>
|
||||||
<StyledHeaderInfo>
|
<StyledHeaderInfo>
|
||||||
<StyledHeaderTitle>
|
<StyledHeaderTitle>
|
||||||
<TextInput
|
<TitleInput
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
sizeVariant="md"
|
||||||
value={title}
|
value={title}
|
||||||
copyButton={false}
|
|
||||||
hotkeyScope="workflow-step-title"
|
|
||||||
onEnter={onTitleChange}
|
|
||||||
onEscape={onTitleChange}
|
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
shouldTrim={false}
|
placeholder={headerType}
|
||||||
|
hotkeyScope="workflow-step-title"
|
||||||
|
onEnter={saveTitle}
|
||||||
|
onEscape={() => {
|
||||||
|
setTitle(initialTitle);
|
||||||
|
}}
|
||||||
|
onClickOutside={saveTitle}
|
||||||
|
onTab={saveTitle}
|
||||||
|
onShiftTab={saveTitle}
|
||||||
/>
|
/>
|
||||||
</StyledHeaderTitle>
|
</StyledHeaderTitle>
|
||||||
<StyledHeaderType>{headerType}</StyledHeaderType>
|
<StyledHeaderType>{headerType}</StyledHeaderType>
|
||||||
|
|||||||
@ -27,7 +27,8 @@ export const Default: Story = {
|
|||||||
play: async ({ canvasElement }) => {
|
play: async ({ canvasElement }) => {
|
||||||
const canvas = within(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();
|
expect(await canvas.findByText('Action')).toBeVisible();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -43,24 +44,25 @@ export const EditableTitle: Story = {
|
|||||||
play: async ({ canvasElement, args }) => {
|
play: async ({ canvasElement, args }) => {
|
||||||
const canvas = within(canvasElement);
|
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 titleInput = await canvas.findByDisplayValue('Create Record');
|
||||||
|
|
||||||
const NEW_TITLE = 'New Title';
|
const NEW_TITLE = 'New Title';
|
||||||
|
|
||||||
await userEvent.clear(titleInput);
|
await userEvent.clear(titleInput);
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(args.onTitleChange).toHaveBeenCalledWith('');
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvent.type(titleInput, NEW_TITLE);
|
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(() => {
|
await waitFor(() => {
|
||||||
expect(args.onTitleChange).toHaveBeenCalledWith(NEW_TITLE);
|
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 }) => {
|
play: async ({ canvasElement, args }) => {
|
||||||
const canvas = within(canvasElement);
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
const titleInput = await canvas.findByDisplayValue('Create Record');
|
// When disabled, TitleInput just shows text in a div, not an input
|
||||||
expect(titleInput).toBeDisabled();
|
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(args.onTitleChange).not.toHaveBeenCalled();
|
||||||
expect(titleInput).toHaveValue('Create Record');
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -73,9 +73,14 @@ export const Disabled: Story = {
|
|||||||
play: async ({ canvasElement }) => {
|
play: async ({ canvasElement }) => {
|
||||||
const canvas = within(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');
|
const objectSelectCurrentValue = await canvas.findByText('People');
|
||||||
|
|
||||||
|
|||||||
@ -77,9 +77,14 @@ export const DisabledWithEmptyValues: Story = {
|
|||||||
play: async ({ canvasElement }) => {
|
play: async ({ canvasElement }) => {
|
||||||
const canvas = within(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');
|
const objectSelectCurrentValue = await canvas.findByText('People');
|
||||||
|
|
||||||
@ -123,9 +128,14 @@ export const DisabledWithDefaultStaticValues: Story = {
|
|||||||
play: async ({ canvasElement }) => {
|
play: async ({ canvasElement }) => {
|
||||||
const canvas = within(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');
|
const objectSelectCurrentValue = await canvas.findByText('People');
|
||||||
|
|
||||||
@ -173,9 +183,14 @@ export const DisabledWithDefaultVariableValues: Story = {
|
|||||||
play: async ({ canvasElement }) => {
|
play: async ({ canvasElement }) => {
|
||||||
const canvas = within(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');
|
const objectSelectCurrentValue = await canvas.findByText('People');
|
||||||
|
|
||||||
|
|||||||
@ -76,9 +76,14 @@ export const DisabledWithEmptyValues: Story = {
|
|||||||
play: async ({ canvasElement }) => {
|
play: async ({ canvasElement }) => {
|
||||||
const canvas = within(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');
|
const objectSelectCurrentValue = await canvas.findByText('People');
|
||||||
|
|
||||||
|
|||||||
@ -90,9 +90,14 @@ export const DisabledWithEmptyValues: Story = {
|
|||||||
play: async ({ canvasElement }) => {
|
play: async ({ canvasElement }) => {
|
||||||
const canvas = within(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');
|
const objectSelectCurrentValue = await canvas.findByText('People');
|
||||||
|
|
||||||
@ -151,9 +156,14 @@ export const DisabledWithDefaultStaticValues: Story = {
|
|||||||
play: async ({ canvasElement }) => {
|
play: async ({ canvasElement }) => {
|
||||||
const canvas = within(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');
|
const objectSelectCurrentValue = await canvas.findByText('People');
|
||||||
|
|
||||||
@ -214,9 +224,14 @@ export const DisabledWithDefaultVariableValues: Story = {
|
|||||||
play: async ({ canvasElement }) => {
|
play: async ({ canvasElement }) => {
|
||||||
const canvas = within(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');
|
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 { WorkflowEditActionFormBuilder } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder';
|
||||||
import { Meta, StoryObj } from '@storybook/react';
|
import { Meta, StoryObj } from '@storybook/react';
|
||||||
import { expect, fn, within } from '@storybook/test';
|
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 { ComponentDecorator, RouterDecorator } from 'twenty-ui';
|
||||||
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
|
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
|
||||||
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
|
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
|
||||||
import { graphqlMocks } from '~/testing/graphqlMocks';
|
import { graphqlMocks } from '~/testing/graphqlMocks';
|
||||||
import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow';
|
import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow';
|
||||||
import { FieldMetadataType } from 'twenty-shared/types';
|
|
||||||
|
|
||||||
const DEFAULT_ACTION = {
|
const DEFAULT_ACTION = {
|
||||||
id: getWorkflowNodeIdMock(),
|
id: getWorkflowNodeIdMock(),
|
||||||
@ -89,9 +90,14 @@ export const DisabledWithEmptyValues: Story = {
|
|||||||
play: async ({ canvasElement }) => {
|
play: async ({ canvasElement }) => {
|
||||||
const canvas = within(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');
|
await canvas.findByText('Company');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user