Open showpage on workflow creation (#9714)

- Created an new component state
`isRecordEditableNameRenamingComponentState`
- Updated `useCreateNewTableRecord` to open the ShowPage on workflow
creation
- Refactored `RecordEditableName` and its components to remove the
useEffect (This was causing the recordName state to be updated after the
focus on `NavigationDrawerInput`, but we want the text so be selected
after the update).
- Introduced a new component `EditableBreadcrumbItem`
- Created an autosizing text input: This is done by a hack using a span
inside a div and the input position is set to absolute and takes the
size of the div. There are two problems that I didn't manage to fix:
If the text is too long, the title overflows, and the letter spacing is
different between the span and the input creating a small offset.


https://github.com/user-attachments/assets/4aa1e177-7458-4691-b0c8-96567b482206


New text input component:


https://github.com/user-attachments/assets/94565546-fe2b-457d-a1d8-907007e0e2ce
This commit is contained in:
Raphaël Bosi
2025-01-22 14:44:10 +01:00
committed by GitHub
parent 441b88b7e1
commit 8213995887
16 changed files with 429 additions and 179 deletions

View File

@ -26,9 +26,6 @@ test('Create workflow', async ({ page }) => {
await createWorkflowButton.click(),
]);
const nameInputClosedState = page.getByText('Name').first();
await nameInputClosedState.click();
const nameInput = page.getByRole('textbox');
await nameInput.fill(NEW_WORKFLOW_NAME);
await nameInput.press('Enter');
@ -37,23 +34,11 @@ test('Create workflow', async ({ page }) => {
const newWorkflowId = body.data.createWorkflow.id;
try {
const newWorkflowRowEntryName = page
.getByTestId(`row-id-${newWorkflowId}`)
.locator('div')
.filter({ hasText: NEW_WORKFLOW_NAME })
.nth(2);
await Promise.all([
page.waitForURL(
(url) => url.pathname === `/object/workflow/${newWorkflowId}`,
),
newWorkflowRowEntryName.click(),
]);
const workflowName = page.getByRole('button', { name: NEW_WORKFLOW_NAME });
await expect(workflowName).toBeVisible();
await expect(page).toHaveURL(`/object/workflow/${newWorkflowId}`);
} finally {
await deleteWorkflow({
page,

View File

@ -3,6 +3,7 @@ import { FavoriteFolderHotkeyScope } from '@/favorites/constants/FavoriteFolderR
import { useCreateFavoriteFolder } from '@/favorites/hooks/useCreateFavoriteFolder';
import { useFavoritesByFolder } from '@/favorites/hooks/useFavoritesByFolder';
import { isFavoriteFolderCreatingState } from '@/favorites/states/isFavoriteFolderCreatingState';
import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper';
import { NavigationDrawerInput } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerInput';
import { useState } from 'react';
import { useRecoilState } from 'recoil';
@ -62,15 +63,19 @@ export const FavoriteFolders = ({
return (
<>
{isFavoriteFolderCreating && (
<NavigationDrawerInput
Icon={IconFolder}
value={newFolderName}
onChange={handleFavoriteFolderNameChange}
onSubmit={handleSubmitFavoriteFolderCreation}
onCancel={handleCancelFavoriteFolderCreation}
onClickOutside={handleClickOutside}
hotkeyScope={FavoriteFolderHotkeyScope.FavoriteFolderNavigationInput}
/>
<NavigationDrawerAnimatedCollapseWrapper>
<NavigationDrawerInput
Icon={IconFolder}
value={newFolderName}
onChange={handleFavoriteFolderNameChange}
onSubmit={handleSubmitFavoriteFolderCreation}
onCancel={handleCancelFavoriteFolderCreation}
onClickOutside={handleClickOutside}
hotkeyScope={
FavoriteFolderHotkeyScope.FavoriteFolderNavigationInput
}
/>
</NavigationDrawerAnimatedCollapseWrapper>
)}
{favoritesByFolder.map((folder) => (
<CurrentWorkspaceMemberFavorites

View File

@ -1,50 +1,49 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { NavigationDrawerInput } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerInput';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { EditableBreadcrumbItem } from '@/ui/navigation/bread-crumb/components/EditableBreadcrumbItem';
import styled from '@emotion/styled';
import { useEffect, useState } from 'react';
import { capitalize } from 'twenty-shared';
const StyledEditableTitleContainer = styled.div`
align-items: flex-start;
align-items: center;
display: flex;
flex-direction: row;
overflow-x: hidden;
`;
const StyledEditableTitlePrefix = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
line-height: 24px;
display: flex;
flex: 1 0 auto;
flex-direction: row;
padding: ${({ theme }) => theme.spacing(0.75)};
gap: ${({ theme }) => theme.spacing(1)};
padding: ${({ theme }) => theme.spacing(0.75)};
`;
export const RecordEditableName = ({
export const ObjectRecordShowPageBreadcrumb = ({
objectNameSingular,
objectRecordId,
objectLabelPlural,
labelIdentifierFieldMetadataItem,
}: {
objectNameSingular: string;
objectRecordId: string;
objectLabelPlural: string;
labelIdentifierFieldMetadataItem?: FieldMetadataItem;
}) => {
const [isRenaming, setIsRenaming] = useState(false);
const { record, loading } = useFindOneRecord({
objectNameSingular,
objectRecordId,
recordGqlFields: {
name: true,
[labelIdentifierFieldMetadataItem?.name ?? 'name']: true,
},
});
const [recordName, setRecordName] = useState(record?.name);
const { updateOneRecord } = useUpdateOneRecord({
objectNameSingular,
recordGqlFields: {
name: true,
[labelIdentifierFieldMetadataItem?.name ?? 'name']: true,
},
});
@ -55,18 +54,8 @@ export const RecordEditableName = ({
name: value,
},
});
setIsRenaming(false);
};
const handleCancel = () => {
setRecordName(record?.name);
setIsRenaming(false);
};
useEffect(() => {
setRecordName(record?.name);
}, [record?.name]);
if (loading) {
return null;
}
@ -77,24 +66,13 @@ export const RecordEditableName = ({
{capitalize(objectLabelPlural)}
<span>{' / '}</span>
</StyledEditableTitlePrefix>
{isRenaming ? (
<NavigationDrawerInput
value={recordName}
onChange={setRecordName}
onSubmit={handleSubmit}
onCancel={handleCancel}
onClickOutside={handleCancel}
hotkeyScope="favorites-folder-input"
/>
) : (
<NavigationDrawerItem
label={recordName}
onClick={() => setIsRenaming(true)}
rightOptions={undefined}
className="navigation-drawer-item"
active
/>
)}
<EditableBreadcrumbItem
defaultValue={record?.name ?? ''}
noValuePlaceholder={labelIdentifierFieldMetadataItem?.label ?? 'Name'}
placeholder={labelIdentifierFieldMetadataItem?.label ?? 'Name'}
onSubmit={handleSubmit}
hotkeyScope="editable-breadcrumb-item"
/>
</StyledEditableTitleContainer>
);
};

View File

@ -5,7 +5,10 @@ import { DEFAULT_CELL_SCOPE } from '@/object-record/record-table/record-table-ce
import { useSelectedTableCellEditMode } from '@/object-record/record-table/record-table-cell/hooks/useSelectedTableCellEditMode';
import { recordTablePendingRecordIdByGroupComponentFamilyState } from '@/object-record/record-table/states/recordTablePendingRecordIdByGroupComponentFamilyState';
import { recordTablePendingRecordIdComponentState } from '@/object-record/record-table/states/recordTablePendingRecordIdComponentState';
import { isUpdatingRecordEditableNameState } from '@/object-record/states/isUpdatingRecordEditableName';
import { getDropdownFocusIdForRecordField } from '@/object-record/utils/getDropdownFocusIdForRecordField';
import { shouldRedirectToShowPageOnCreation } from '@/object-record/utils/shouldRedirectToShowPageOnCreation';
import { AppPath } from '@/types/AppPath';
import { useSetActiveDropdownFocusIdAndMemorizePrevious } from '@/ui/layout/dropdown/hooks/useSetFocusedDropdownIdAndMemorizePrevious';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
@ -14,6 +17,7 @@ import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useRecoilCallback } from 'recoil';
import { v4 } from 'uuid';
import { FeatureFlagKey } from '~/generated/graphql';
import { useNavigateApp } from '~/hooks/useNavigateApp';
import { isDefined } from '~/utils/isDefined';
export const useCreateNewTableRecord = ({
@ -54,30 +58,69 @@ export const useCreateNewTableRecord = ({
shouldMatchRootQueryFilter: true,
});
const createNewTableRecord = async () => {
const recordId = v4();
const navigate = useNavigateApp();
if (isCommandMenuV2Enabled) {
await createOneRecord({ id: recordId });
const createNewTableRecord = useRecoilCallback(
({ set }) =>
async () => {
const recordId = v4();
openRecordInCommandMenu(recordId, objectMetadataItem.nameSingular);
return;
}
if (isCommandMenuV2Enabled) {
// TODO: Generalize this behaviour, there will be a view setting to specify
// if the new record should be displayed in the side panel or on the record page
if (
shouldRedirectToShowPageOnCreation(objectMetadataItem.nameSingular)
) {
await createOneRecord({
id: recordId,
name: 'Untitled',
});
setPendingRecordId(recordId);
setSelectedTableCellEditMode(-1, 0);
setHotkeyScope(DEFAULT_CELL_SCOPE.scope, DEFAULT_CELL_SCOPE.customScopes);
navigate(AppPath.RecordShowPage, {
objectNameSingular: objectMetadataItem.nameSingular,
objectRecordId: recordId,
});
if (isDefined(objectMetadataItem.labelIdentifierFieldMetadataId)) {
setActiveDropdownFocusIdAndMemorizePrevious(
getDropdownFocusIdForRecordField(
recordId,
objectMetadataItem.labelIdentifierFieldMetadataId,
'table-cell',
),
);
}
};
set(isUpdatingRecordEditableNameState, true);
return;
}
await createOneRecord({ id: recordId });
openRecordInCommandMenu(recordId, objectMetadataItem.nameSingular);
return;
}
setPendingRecordId(recordId);
setSelectedTableCellEditMode(-1, 0);
setHotkeyScope(
DEFAULT_CELL_SCOPE.scope,
DEFAULT_CELL_SCOPE.customScopes,
);
if (isDefined(objectMetadataItem.labelIdentifierFieldMetadataId)) {
setActiveDropdownFocusIdAndMemorizePrevious(
getDropdownFocusIdForRecordField(
recordId,
objectMetadataItem.labelIdentifierFieldMetadataId,
'table-cell',
),
);
}
},
[
createOneRecord,
isCommandMenuV2Enabled,
navigate,
objectMetadataItem.labelIdentifierFieldMetadataId,
objectMetadataItem.nameSingular,
openRecordInCommandMenu,
setActiveDropdownFocusIdAndMemorizePrevious,
setHotkeyScope,
setPendingRecordId,
setSelectedTableCellEditMode,
],
);
const createNewTableRecordInGroup = useRecoilCallback(
({ set }) =>

View File

@ -0,0 +1,6 @@
import { createState } from 'twenty-ui';
export const isUpdatingRecordEditableNameState = createState<boolean>({
key: 'isUpdatingRecordEditableNameState',
defaultValue: false,
});

View File

@ -0,0 +1,11 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
export const shouldRedirectToShowPageOnCreation = (
objectNameSingular: string,
) => {
if (objectNameSingular === CoreObjectNameSingular.Workflow) {
return true;
}
return false;
};

View File

@ -1,3 +1,4 @@
import { InputLabel } from '@/ui/input/components/InputLabel';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
@ -10,28 +11,37 @@ import {
useRef,
useState,
} from 'react';
import { IconComponent, IconEye, IconEyeOff, RGBA } from 'twenty-ui';
import {
ComputeNodeDimensions,
IconComponent,
IconEye,
IconEyeOff,
RGBA,
} from 'twenty-ui';
import { useCombinedRefs } from '~/hooks/useCombinedRefs';
import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly';
import { InputLabel } from './InputLabel';
const StyledContainer = styled.div<
Pick<TextInputV2ComponentProps, 'fullWidth'>
>`
box-sizing: border-box;
display: inline-flex;
flex-direction: column;
width: ${({ fullWidth }) => (fullWidth ? `100%` : 'auto')};
`;
const StyledInputContainer = styled.div`
background-color: inherit;
display: flex;
flex-direction: row;
width: 100%;
position: relative;
`;
const StyledInput = styled.input<
Pick<TextInputV2ComponentProps, 'fullWidth' | 'LeftIcon' | 'error'>
Pick<
TextInputV2ComponentProps,
'LeftIcon' | 'error' | 'sizeVariant' | 'width'
>
>`
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid
@ -44,12 +54,14 @@ const StyledInput = styled.input<
flex-grow: 1;
font-family: ${({ theme }) => theme.font.family};
font-weight: ${({ theme }) => theme.font.weight.regular};
height: 32px;
height: ${({ sizeVariant }) => (sizeVariant === 'sm' ? '20px' : '32px')};
outline: none;
padding: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme, sizeVariant }) =>
sizeVariant === 'sm' ? `${theme.spacing(2)} 0` : theme.spacing(2)};
padding-left: ${({ theme, LeftIcon }) =>
LeftIcon ? `calc(${theme.spacing(4)} + 16px)` : theme.spacing(2)};
width: 100%;
LeftIcon ? `px` : theme.spacing(2)};
width: ${({ theme, width }) =>
width ? `calc(${width}px + ${theme.spacing(5)})` : '100%'};
&::placeholder,
&::-webkit-input-placeholder {
@ -111,6 +123,8 @@ const StyledTrailingIcon = styled.div`
const INPUT_TYPE_PASSWORD = 'password';
export type TextInputV2Size = 'sm' | 'md';
export type TextInputV2ComponentProps = Omit<
InputHTMLAttributes<HTMLInputElement>,
'onChange' | 'onKeyDown'
@ -123,11 +137,15 @@ export type TextInputV2ComponentProps = Omit<
noErrorHelper?: boolean;
RightIcon?: IconComponent;
LeftIcon?: IconComponent;
autoGrow?: boolean;
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
onBlur?: FocusEventHandler<HTMLInputElement>;
dataTestId?: string;
sizeVariant?: TextInputV2Size;
};
type TextInputV2WithAutoGrowWrapperProps = TextInputV2ComponentProps;
const TextInputV2Component = (
{
className,
@ -138,6 +156,7 @@ const TextInputV2Component = (
onBlur,
onKeyDown,
fullWidth,
width,
error,
noErrorHelper = false,
required,
@ -150,6 +169,7 @@ const TextInputV2Component = (
LeftIcon,
autoComplete,
maxLength,
sizeVariant = 'md',
dataTestId,
}: TextInputV2ComponentProps,
// eslint-disable-next-line @nx/workspace-component-props-naming
@ -183,8 +203,10 @@ const TextInputV2Component = (
</StyledTrailingIcon>
</StyledLeftIconContainer>
)}
<StyledInput
id={inputId}
width={width}
data-testid={dataTestId}
autoComplete={autoComplete || 'off'}
ref={combinedRef}
@ -207,8 +229,10 @@ const TextInputV2Component = (
LeftIcon,
maxLength,
error,
sizeVariant,
}}
/>
<StyledTrailingIconContainer {...{ error }}>
{!error && type === INPUT_TYPE_PASSWORD && (
<StyledTrailingIcon
@ -236,4 +260,22 @@ const TextInputV2Component = (
);
};
export const TextInputV2 = forwardRef(TextInputV2Component);
const TextInputV2WithAutoGrowWrapper = (
props: TextInputV2WithAutoGrowWrapperProps,
) => (
<>
{props.autoGrow ? (
<ComputeNodeDimensions node={props.value || props.placeholder}>
{(nodeDimensions) => (
// eslint-disable-next-line
<TextInputV2Component {...props} width={nodeDimensions?.width} />
)}
</ComputeNodeDimensions>
) : (
// eslint-disable-next-line
<TextInputV2Component {...props} />
)}
</>
);
export const TextInputV2 = forwardRef(TextInputV2WithAutoGrowWrapper);

View File

@ -1,5 +1,5 @@
import { useState } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';
import { ComponentDecorator } from 'twenty-ui';
import {
@ -40,3 +40,19 @@ export const Filled: Story = {
export const Disabled: Story = {
args: { disabled: true, value: 'Tim' },
};
export const AutoGrow: Story = {
args: { autoGrow: true, value: 'Tim' },
};
export const AutoGrowWithPlaceholder: Story = {
args: { autoGrow: true, placeholder: 'Tim' },
};
export const Small: Story = {
args: { sizeVariant: 'sm', value: 'Tim' },
};
export const AutoGrowSmall: Story = {
args: { autoGrow: true, sizeVariant: 'sm', value: 'Tim' },
};

View File

@ -34,9 +34,9 @@ const StyledTopBarContainer = styled.div`
padding: ${({ theme }) => theme.spacing(2)};
padding-left: 0;
padding-right: ${({ theme }) => theme.spacing(3)};
gap: ${({ theme }) => theme.spacing(2)};
@media (max-width: ${MOBILE_VIEWPORT}px) {
width: 100%;
box-sizing: border-box;
padding: ${({ theme }) => theme.spacing(3)};
}
@ -48,7 +48,7 @@ const StyledLeftContainer = styled.div`
flex-direction: row;
gap: ${({ theme }) => theme.spacing(1)};
padding-left: ${({ theme }) => theme.spacing(1)};
width: 100%;
overflow-x: hidden;
@media (max-width: ${MOBILE_VIEWPORT}px) {
padding-left: ${({ theme }) => theme.spacing(1)};
@ -60,21 +60,19 @@ const StyledTitleContainer = styled.div`
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.medium};
margin-left: ${({ theme }) => theme.spacing(1)};
width: 100%;
`;
const StyledTopBarIconStyledTitleContainer = styled.div`
align-items: center;
display: flex;
flex: 1 0 auto;
gap: ${({ theme }) => theme.spacing(1)};
flex-direction: row;
width: 100%;
`;
const StyledPageActionContainer = styled.div`
display: inline-flex;
gap: ${({ theme }) => theme.spacing(2)};
flex: 1 0 1;
`;
const StyledTopBarButtonContainer = styled.div`
@ -82,6 +80,13 @@ const StyledTopBarButtonContainer = styled.div`
margin-right: ${({ theme }) => theme.spacing(1)};
`;
const StyledIconContainer = styled.div`
flex: 1 0 1;
display: flex;
flex-direction: row;
align-items: center;
`;
type PageHeaderProps = {
title?: ReactNode;
hasClosePageButton?: boolean;
@ -149,7 +154,9 @@ export const PageHeader = ({
/>
</>
)}
{Icon && <Icon size={theme.icon.size.md} />}
<StyledIconContainer>
{Icon && <Icon size={theme.icon.size.md} />}
</StyledIconContainer>
{title && (
<StyledTitleContainer data-testid="top-bar-title">
{typeof title === 'string' ? (

View File

@ -0,0 +1,120 @@
import { isUpdatingRecordEditableNameState } from '@/object-record/states/isUpdatingRecordEditableName';
import { TextInputV2 } from '@/ui/input/components/TextInputV2';
import { useOpenEditableBreadCrumbItem } from '@/ui/navigation/bread-crumb/hooks/useOpenEditableBreadCrumbItem';
import { EditableBreadcrumbItemHotkeyScope } from '@/ui/navigation/bread-crumb/types/EditableBreadcrumbItemHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import styled from '@emotion/styled';
import { useRef, useState } from 'react';
import { useRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import { isDefined } from 'twenty-ui';
import { useHotkeyScopeOnMount } from '~/hooks/useHotkeyScopeOnMount';
type EditableBreadcrumbItemProps = {
className?: string;
defaultValue: string;
noValuePlaceholder?: string;
placeholder: string;
onSubmit: (value: string) => void;
hotkeyScope: string;
};
const StyledButton = styled('button')`
align-items: center;
background: inherit;
border: none;
border-radius: ${({ theme }) => theme.border.radius.sm};
box-sizing: content-box;
color: ${({ theme }) => theme.font.color.primary};
cursor: pointer;
display: flex;
font-family: ${({ theme }) => theme.font.family};
font-size: ${({ theme }) => theme.font.size.md};
height: 20px;
overflow: hidden;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
:hover {
background: ${({ theme }) => theme.background.transparent.light};
}
`;
export const EditableBreadcrumbItem = ({
className,
defaultValue,
noValuePlaceholder,
placeholder,
onSubmit,
}: EditableBreadcrumbItemProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const [isUpdatingRecordEditableName, setIsUpdatingRecordEditableName] =
useRecoilState(isUpdatingRecordEditableNameState);
// TODO: remove this and set the hokey scopes synchronously on page change inside the useNavigateApp hook
useHotkeyScopeOnMount(
EditableBreadcrumbItemHotkeyScope.EditableBreadcrumbItem,
);
useScopedHotkeys(
[Key.Escape],
() => {
setIsUpdatingRecordEditableName(false);
},
EditableBreadcrumbItemHotkeyScope.EditableBreadcrumbItem,
);
useScopedHotkeys(
[Key.Enter],
() => {
onSubmit(value);
setIsUpdatingRecordEditableName(false);
},
EditableBreadcrumbItemHotkeyScope.EditableBreadcrumbItem,
);
const clickOutsideRefs: Array<React.RefObject<HTMLElement>> = [
inputRef,
buttonRef,
];
useListenClickOutside({
refs: clickOutsideRefs,
callback: () => {
setIsUpdatingRecordEditableName(false);
},
listenerId: 'editable-breadcrumb-item',
});
const handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
if (isDefined(value)) {
event.target.select();
}
};
const [value, setValue] = useState<string>(defaultValue);
const { openEditableBreadCrumbItem } = useOpenEditableBreadCrumbItem();
return isUpdatingRecordEditableName ? (
<TextInputV2
className={className}
autoGrow
sizeVariant="sm"
ref={inputRef}
value={value}
onChange={setValue}
placeholder={placeholder}
onFocus={handleFocus}
autoFocus
/>
) : (
<StyledButton ref={buttonRef} onClick={openEditableBreadCrumbItem}>
{value || noValuePlaceholder}
</StyledButton>
);
};

View File

@ -0,0 +1,68 @@
import { expect, jest } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react';
import { RecoilRoot } from 'recoil';
import { EditableBreadcrumbItemHotkeyScope } from '@/ui/navigation/bread-crumb/types/EditableBreadcrumbItemHotkeyScope';
import { findByText, userEvent } from '@storybook/test';
import { ComponentDecorator } from 'twenty-ui';
import { EditableBreadcrumbItem } from '../EditableBreadcrumbItem';
const onSubmit = jest.fn();
const meta: Meta<typeof EditableBreadcrumbItem> = {
title: 'UI/Navigation/BreadCrumb/EditableBreadcrumbItem',
component: EditableBreadcrumbItem,
decorators: [
(Story) => (
<RecoilRoot>
<Story />
</RecoilRoot>
),
ComponentDecorator,
],
args: {
defaultValue: 'Company Name',
placeholder: 'Enter name',
hotkeyScope: EditableBreadcrumbItemHotkeyScope.EditableBreadcrumbItem,
onSubmit,
},
};
export default meta;
type Story = StoryObj<typeof EditableBreadcrumbItem>;
export const Default: Story = {
args: {},
play: async ({ canvasElement }) => {
const button = await findByText(canvasElement, 'Company Name');
expect(button).toBeInTheDocument();
},
};
export const Editing: Story = {
args: {},
play: async ({ canvasElement }) => {
const button = canvasElement.querySelector('button');
await userEvent.click(button);
await new Promise((resolve) => setTimeout(resolve, 100));
await userEvent.keyboard('New Name');
await userEvent.keyboard('{Enter}');
expect(onSubmit).toHaveBeenCalledWith('New Name');
},
};
export const WithNoValue: Story = {
args: {
defaultValue: '',
noValuePlaceholder: 'Untitled',
},
play: async ({ canvasElement }) => {
const button = await findByText(canvasElement, 'Untitled');
expect(button).toBeInTheDocument();
},
};

View File

@ -0,0 +1,19 @@
import { isUpdatingRecordEditableNameState } from '@/object-record/states/isUpdatingRecordEditableName';
import { EditableBreadcrumbItemHotkeyScope } from '@/ui/navigation/bread-crumb/types/EditableBreadcrumbItemHotkeyScope';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { useSetRecoilState } from 'recoil';
export const useOpenEditableBreadCrumbItem = () => {
const setIsUpdatingRecordEditableName = useSetRecoilState(
isUpdatingRecordEditableNameState,
);
const setHotkeyScope = useSetHotkeyScope();
const openEditableBreadCrumbItem = () => {
setIsUpdatingRecordEditableName(true);
setHotkeyScope(EditableBreadcrumbItemHotkeyScope.EditableBreadcrumbItem);
};
return { openEditableBreadCrumbItem };
};

View File

@ -0,0 +1,3 @@
export enum EditableBreadcrumbItemHotkeyScope {
EditableBreadcrumbItem = 'editable-breadcrumb-item',
}

View File

@ -1,23 +1,16 @@
import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper';
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
import { TextInputV2 } from '@/ui/input/components/TextInputV2';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { ChangeEvent, FocusEvent, useRef } from 'react';
import { useRecoilState } from 'recoil';
import { FocusEvent, useRef } from 'react';
import { Key } from 'ts-key-enum';
import {
IconComponent,
isDefined,
TablerIconsProps,
TEXT_INPUT_STYLE,
} from 'twenty-ui';
import { IconComponent, TablerIconsProps, isDefined } from 'twenty-ui';
import { useHotkeyScopeOnMount } from '~/hooks/useHotkeyScopeOnMount';
type NavigationDrawerInputProps = {
className?: string;
Icon?: IconComponent | ((props: TablerIconsProps) => JSX.Element);
placeholder?: string;
value: string;
onChange: (value: string) => void;
onSubmit: (value: string) => void;
@ -26,38 +19,13 @@ type NavigationDrawerInputProps = {
hotkeyScope: string;
};
const StyledItem = styled.div<{ isNavigationDrawerExpanded: boolean }>`
align-items: center;
background-color: ${({ theme }) => theme.background.primary};
border: 1px solid ${({ theme }) => theme.color.blue};
border-radius: ${({ theme }) => theme.border.radius.sm};
box-sizing: content-box;
color: ${({ theme }) => theme.font.color.primary};
display: flex;
font-family: ${({ theme }) => theme.font.family};
font-size: ${({ theme }) => theme.font.size.md};
height: calc(${({ theme }) => theme.spacing(5)} - 2px);
padding: ${({ theme }) => theme.spacing(1)};
text-decoration: none;
user-select: none;
`;
const StyledItemElementsContainer = styled.span`
align-items: center;
gap: ${({ theme }) => theme.spacing(2)};
display: flex;
width: 100%;
`;
const StyledTextInput = styled.input`
${TEXT_INPUT_STYLE}
margin: 0;
width: 100%;
padding: 0;
const StyledInput = styled(TextInputV2)`
background-color: white;
`;
export const NavigationDrawerInput = ({
className,
placeholder,
Icon,
value,
onChange,
@ -66,10 +34,6 @@ export const NavigationDrawerInput = ({
onClickOutside,
hotkeyScope,
}: NavigationDrawerInputProps) => {
const theme = useTheme();
const [isNavigationDrawerExpanded] = useRecoilState(
isNavigationDrawerExpandedState,
);
const inputRef = useRef<HTMLInputElement>(null);
useHotkeyScopeOnMount(hotkeyScope);
@ -99,10 +63,6 @@ export const NavigationDrawerInput = ({
listenerId: 'navigation-drawer-input',
});
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
onChange(event.target.value);
};
const handleFocus = (event: FocusEvent<HTMLInputElement>) => {
if (isDefined(value)) {
event.target.select();
@ -110,29 +70,16 @@ export const NavigationDrawerInput = ({
};
return (
<StyledItem
<StyledInput
className={className}
isNavigationDrawerExpanded={isNavigationDrawerExpanded}
>
<StyledItemElementsContainer>
{Icon && (
<Icon
style={{ minWidth: theme.icon.size.md }}
size={theme.icon.size.md}
stroke={theme.icon.stroke.md}
color="currentColor"
/>
)}
<NavigationDrawerAnimatedCollapseWrapper>
<StyledTextInput
ref={inputRef}
value={value}
onChange={handleChange}
onFocus={handleFocus}
autoFocus
/>
</NavigationDrawerAnimatedCollapseWrapper>
</StyledItemElementsContainer>
</StyledItem>
LeftIcon={Icon}
ref={inputRef}
value={value}
onChange={onChange}
placeholder={placeholder}
onFocus={handleFocus}
fullWidth
autoFocus
/>
);
};

View File

@ -1,5 +0,0 @@
import { TextInput } from '@/ui/input/components/TextInput';
export const NavigationDrawerItemInput = () => {
return <TextInput />;
};

View File

@ -1,5 +1,6 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { RecordEditableName } from '@/object-record/components/RecordEditableName';
import { getObjectMetadataIdentifierFields } from '@/object-metadata/utils/getObjectMetadataIdentifierFields';
import { ObjectRecordShowPageBreadcrumb } from '@/object-record/record-show/components/ObjectRecordShowPageBreadcrumb';
import { useRecordShowContainerTabs } from '@/object-record/record-show/hooks/useRecordShowContainerTabs';
import { useRecordShowPage } from '@/object-record/record-show/hooks/useRecordShowPage';
import { useRecordShowPagePagination } from '@/object-record/record-show/hooks/useRecordShowPagePagination';
@ -34,14 +35,18 @@ export const RecordShowPageHeader = ({
const hasEditableName = layout.hideSummaryAndFields === true;
const { labelIdentifierFieldMetadataItem } =
getObjectMetadataIdentifierFields({ objectMetadataItem });
return (
<PageHeader
title={
hasEditableName ? (
<RecordEditableName
<ObjectRecordShowPageBreadcrumb
objectNameSingular={objectNameSingular}
objectRecordId={objectRecordId}
objectLabelPlural={objectMetadataItem.labelPlural}
labelIdentifierFieldMetadataItem={labelIdentifierFieldMetadataItem}
/>
) : (
viewName