384 update the input of the record show page inside the command menu (#10213)

Created a new component `RecordTitleCell` with an API close to
`RecordInlineCell`.
This new component is an autogrowing input. 
It consumes the `FieldContext`. It uses some hooks and states from
`RecordInlineCell` because I didn't want to duplicate all the logic, but
this logic could be duplicated.

Two issues that I didn't solve in this PR:
- There is a flashing glitch inside the input when typing
- The input of a workflow isn't focused when creating a new one. This is
because of an issue with the `useHotkeyScopeOnMount` hook which is
deprecated but still used in some components. Upon redirection on the
workflow showpage, the hokey scope of the input is overridden by the
hokey scopes of the components which use `useHotkeyScopeOnMount`. I
decided not to open the input for now.

## Command menu record show page

### Single input


https://github.com/user-attachments/assets/50dc235c-8f34-4445-8b04-586125606bd5

### Double input


https://github.com/user-attachments/assets/bdcfd6eb-d25e-4006-a87f-6e615e8a6e7e

## Workflow breadcrumb


https://github.com/user-attachments/assets/ded38dd6-5794-4779-a4ae-b3948567595a

## Record show page

### Single input


https://github.com/user-attachments/assets/8ad7a606-556a-416b-8788-93415f7989e1

### Double input


https://github.com/user-attachments/assets/55aae40b-36ae-40f1-8171-06f1a5db3532
This commit is contained in:
Raphaël Bosi
2025-02-14 13:33:18 +01:00
committed by GitHub
parent a4a085392d
commit 80c55b4462
85 changed files with 1415 additions and 757 deletions

View File

@ -1,26 +1,35 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { EditableBreadcrumbItem } from '@/ui/navigation/bread-crumb/components/EditableBreadcrumbItem';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope';
import { useRecordShowContainerActions } from '@/object-record/record-show/hooks/useRecordShowContainerActions';
import { RecordTitleCell } from '@/object-record/record-title-cell/components/RecordTitleCell';
import styled from '@emotion/styled';
import { capitalize } from 'twenty-shared';
import { FieldMetadataType, capitalize } from 'twenty-shared';
const StyledEditableTitleContainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
overflow-x: hidden;
width: 100%;
`;
const StyledEditableTitlePrefix = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
flex: 1 0 auto;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(1)};
padding: ${({ theme }) => theme.spacing(0.75)};
`;
const StyledTitle = styled.div`
max-width: 100%;
overflow: hidden;
padding-right: ${({ theme }) => theme.spacing(1)};
width: fit-content;
`;
export const ObjectRecordShowPageBreadcrumb = ({
objectNameSingular,
objectRecordId,
@ -40,22 +49,12 @@ export const ObjectRecordShowPageBreadcrumb = ({
},
});
const { updateOneRecord } = useUpdateOneRecord({
const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({
objectNameSingular,
recordGqlFields: {
[labelIdentifierFieldMetadataItem?.name ?? 'name']: true,
},
objectRecordId,
recordFromStore: record ?? null,
});
const handleSubmit = (value: string) => {
updateOneRecord({
idToUpdate: objectRecordId,
updateOneRecordInput: {
name: value,
},
});
};
if (loading) {
return null;
}
@ -66,13 +65,35 @@ export const ObjectRecordShowPageBreadcrumb = ({
{capitalize(objectLabelPlural)}
<span>{' / '}</span>
</StyledEditableTitlePrefix>
<EditableBreadcrumbItem
defaultValue={record?.name ?? ''}
noValuePlaceholder={labelIdentifierFieldMetadataItem?.label ?? 'Name'}
placeholder={labelIdentifierFieldMetadataItem?.label ?? 'Name'}
onSubmit={handleSubmit}
hotkeyScope="editable-breadcrumb-item"
/>
<StyledTitle>
<FieldContext.Provider
value={{
recordId: objectRecordId,
recoilScopeId:
objectRecordId + labelIdentifierFieldMetadataItem?.id,
isLabelIdentifier: false,
fieldDefinition: {
type:
labelIdentifierFieldMetadataItem?.type ||
FieldMetadataType.TEXT,
iconName: '',
fieldMetadataId: labelIdentifierFieldMetadataItem?.id ?? '',
label: labelIdentifierFieldMetadataItem?.label || '',
metadata: {
fieldName: labelIdentifierFieldMetadataItem?.name || '',
objectMetadataNameSingular: objectNameSingular,
},
defaultValue: labelIdentifierFieldMetadataItem?.defaultValue,
},
useUpdateRecord: useUpdateOneObjectRecordMutation,
hotkeyScope: InlineCellHotkeyScope.InlineCell,
isCentered: false,
isDisplayModeFixHeight: true,
}}
>
<RecordTitleCell sizeVariant="sm" />
</FieldContext.Provider>
</StyledTitle>
</StyledEditableTitleContainer>
);
};

View File

@ -7,11 +7,13 @@ import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/
import { RightDrawerTitleRecordInlineCell } from '@/object-record/record-right-drawer/components/RightDrawerTitleRecordInlineCell';
import { useRecordShowContainerActions } from '@/object-record/record-show/hooks/useRecordShowContainerActions';
import { useRecordShowContainerData } from '@/object-record/record-show/hooks/useRecordShowContainerData';
import { RecordTitleCell } from '@/object-record/record-title-cell/components/RecordTitleCell';
import { ShowPageSummaryCard } from '@/ui/layout/show-page/components/ShowPageSummaryCard';
import { ShowPageSummaryCardSkeletonLoader } from '@/ui/layout/show-page/components/ShowPageSummaryCardSkeletonLoader';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { isDefined } from 'twenty-shared';
import { FieldMetadataType } from '~/generated/graphql';
import { FeatureFlagKey, FieldMetadataType } from '~/generated/graphql';
type SummaryCardProps = {
objectNameSingular: string;
@ -53,6 +55,10 @@ export const SummaryCard = ({
isRecordDeleted: recordFromStore?.isDeleted,
});
const isCommandMenuV2Enabled = useIsFeatureEnabled(
FeatureFlagKey.IsCommandMenuV2Enabled,
);
if (isNewRightDrawerItemLoading || !isDefined(recordFromStore)) {
return <ShowPageSummaryCardSkeletonLoader />;
}
@ -93,7 +99,9 @@ export const SummaryCard = ({
isDisplayModeFixHeight: true,
}}
>
{isInRightDrawer ? (
{isCommandMenuV2Enabled ? (
<RecordTitleCell sizeVariant="md" />
) : isInRightDrawer ? (
<RightDrawerTitleRecordInlineCell />
) : (
<RecordInlineCell readonly={isReadOnly} />

View File

@ -5,7 +5,7 @@ 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 { useRecordTitleCell } from '@/object-record/record-title-cell/hooks/useRecordTitleCell';
import { getDropdownFocusIdForRecordField } from '@/object-record/utils/getDropdownFocusIdForRecordField';
import { shouldRedirectToShowPageOnCreation } from '@/object-record/utils/shouldRedirectToShowPageOnCreation';
import { AppPath } from '@/types/AppPath';
@ -60,67 +60,59 @@ export const useCreateNewTableRecord = ({
const navigate = useNavigateApp();
const createNewTableRecord = useRecoilCallback(
({ set }) =>
async () => {
const recordId = v4();
const { openRecordTitleCell } = useRecordTitleCell();
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',
});
const createNewTableRecord = async () => {
const recordId = v4();
navigate(AppPath.RecordShowPage, {
objectNameSingular: objectMetadataItem.nameSingular,
objectRecordId: recordId,
});
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',
});
set(isUpdatingRecordEditableNameState, true);
return;
}
navigate(AppPath.RecordShowPage, {
objectNameSingular: objectMetadataItem.nameSingular,
objectRecordId: recordId,
});
await createOneRecord({ id: recordId });
openRecordInCommandMenu(recordId, objectMetadataItem.nameSingular);
// TODO: we should open the record title cell here but because
// we are redirecting to the record show page, the hotkey scope will
// be overridden by the hotkey scope on mount. We need to deprecate
// the useHotkeyScopeOnMount hook.
return;
}
return;
}
setPendingRecordId(recordId);
setSelectedTableCellEditMode(-1, 0);
setHotkeyScope(
DEFAULT_CELL_SCOPE.scope,
DEFAULT_CELL_SCOPE.customScopes,
);
await createOneRecord({ id: recordId });
if (isDefined(objectMetadataItem.labelIdentifierFieldMetadataId)) {
setActiveDropdownFocusIdAndMemorizePrevious(
getDropdownFocusIdForRecordField(
recordId,
objectMetadataItem.labelIdentifierFieldMetadataId,
'table-cell',
),
);
}
},
[
createOneRecord,
isCommandMenuV2Enabled,
navigate,
objectMetadataItem.labelIdentifierFieldMetadataId,
objectMetadataItem.nameSingular,
openRecordInCommandMenu,
setActiveDropdownFocusIdAndMemorizePrevious,
setHotkeyScope,
setPendingRecordId,
setSelectedTableCellEditMode,
],
);
openRecordInCommandMenu(recordId, objectMetadataItem.nameSingular);
openRecordTitleCell({
recordId,
fieldMetadataId: objectMetadataItem.labelIdentifierFieldMetadataId,
});
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',
),
);
}
};
const createNewTableRecordInGroup = useRecoilCallback(
({ set }) =>

View File

@ -0,0 +1,109 @@
import { useContext } from 'react';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { FieldFocusContextProvider } from '@/object-record/record-field/contexts/FieldFocusContextProvider';
import { useIsFieldInputOnly } from '@/object-record/record-field/hooks/useIsFieldInputOnly';
import { FieldInputEvent } from '@/object-record/record-field/types/FieldInputEvent';
import { useInlineCell } from '../../record-inline-cell/hooks/useInlineCell';
import { FieldInputClickOutsideEvent } from '@/object-record/record-field/meta-types/input/components/DateTimeFieldInput';
import { RecordTitleCellContainer } from '@/object-record/record-title-cell/components/RecordTitleCellContainer';
import {
RecordTitleCellContext,
RecordTitleCellContextProps,
} from '@/object-record/record-title-cell/components/RecordTitleCellContext';
import { RecordTitleCellFieldDisplay } from '@/object-record/record-title-cell/components/RecordTitleCellFieldDisplay';
import { RecordTitleCellFieldInput } from '@/object-record/record-title-cell/components/RecordTitleCellFieldInput';
import { getDropdownFocusIdForRecordField } from '@/object-record/utils/getDropdownFocusIdForRecordField';
import { getRecordFieldInputId } from '@/object-record/utils/getRecordFieldInputId';
import { activeDropdownFocusIdState } from '@/ui/layout/dropdown/states/activeDropdownFocusIdState';
import { useRecoilCallback } from 'recoil';
type RecordTitleCellProps = {
loading?: boolean;
sizeVariant?: 'sm' | 'md';
};
export const RecordTitleCell = ({
loading,
sizeVariant,
}: RecordTitleCellProps) => {
const { fieldDefinition, recordId } = useContext(FieldContext);
const isFieldInputOnly = useIsFieldInputOnly();
const { closeInlineCell } = useInlineCell();
const handleEnter: FieldInputEvent = (persistField) => {
persistField();
closeInlineCell();
};
const handleEscape = () => {
closeInlineCell();
};
const handleTab: FieldInputEvent = (persistField) => {
persistField();
closeInlineCell();
};
const handleShiftTab: FieldInputEvent = (persistField) => {
persistField();
closeInlineCell();
};
const handleClickOutside: FieldInputClickOutsideEvent = useRecoilCallback(
({ snapshot }) =>
(persistField, event) => {
const recordFieldDropdownId = getDropdownFocusIdForRecordField(
recordId,
fieldDefinition.fieldMetadataId,
'inline-cell',
);
const activeDropdownFocusId = snapshot
.getLoadable(activeDropdownFocusIdState)
.getValue();
if (recordFieldDropdownId !== activeDropdownFocusId) {
return;
}
event.stopImmediatePropagation();
persistField();
closeInlineCell();
},
[closeInlineCell, fieldDefinition.fieldMetadataId, recordId],
);
const recordTitleCellContextValue: RecordTitleCellContextProps = {
editModeContent: (
<RecordTitleCellFieldInput
recordFieldInputId={getRecordFieldInputId(
recordId,
fieldDefinition?.metadata?.fieldName,
)}
onEnter={handleEnter}
onEscape={handleEscape}
onTab={handleTab}
onShiftTab={handleShiftTab}
onClickOutside={handleClickOutside}
sizeVariant={sizeVariant}
/>
),
displayModeContent: <RecordTitleCellFieldDisplay />,
editModeContentOnly: isFieldInputOnly,
loading: loading,
};
return (
<FieldFocusContextProvider>
<RecordTitleCellContext.Provider value={recordTitleCellContextValue}>
<RecordTitleCellContainer />
</RecordTitleCellContext.Provider>
</FieldFocusContextProvider>
);
};

View File

@ -0,0 +1,13 @@
import { useInlineCell } from '@/object-record/record-inline-cell/hooks/useInlineCell';
import { RecordTitleCellContext } from '@/object-record/record-title-cell/components/RecordTitleCellContext';
import { useContext } from 'react';
export const RecordTitleCellContainer = () => {
const { displayModeContent, editModeContent } = useContext(
RecordTitleCellContext,
);
const { isInlineCellInEditMode } = useInlineCell();
return <>{isInlineCellInEditMode ? editModeContent : displayModeContent}</>;
};

View File

@ -0,0 +1,18 @@
import { createContext, ReactElement } from 'react';
export type RecordTitleCellContextProps = {
editModeContent?: ReactElement;
editModeContentOnly?: boolean;
displayModeContent?: ReactElement;
loading?: boolean;
};
const defaultRecordTitleCellContextProp: RecordTitleCellContextProps = {
editModeContent: undefined,
editModeContentOnly: false,
displayModeContent: undefined,
loading: false,
};
export const RecordTitleCellContext =
createContext<RecordTitleCellContextProps>(defaultRecordTitleCellContextProp);

View File

@ -0,0 +1,24 @@
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText';
import { RecordTitleCellSingleTextDisplayMode } from '@/object-record/record-title-cell/components/RecordTitleCellTextFieldDisplay';
import { RecordTitleFullNameFieldDisplay } from '@/object-record/record-title-cell/components/RecordTitleFullNameFieldDisplay';
import { useContext } from 'react';
export const RecordTitleCellFieldDisplay = () => {
const { fieldDefinition } = useContext(FieldContext);
if (!isFieldText(fieldDefinition) && !isFieldFullName(fieldDefinition)) {
throw new Error('Field definition is not a text or full name field');
}
return (
<>
{isFieldText(fieldDefinition) ? (
<RecordTitleCellSingleTextDisplayMode />
) : isFieldFullName(fieldDefinition) ? (
<RecordTitleFullNameFieldDisplay />
) : null}
</>
);
};

View File

@ -0,0 +1,66 @@
import { useContext } from 'react';
import { RecordFieldInputScope } from '@/object-record/record-field/scopes/RecordFieldInputScope';
import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { FieldInputEvent } from '@/object-record/record-field/types/FieldInputEvent';
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText';
import { RecordTitleCellTextFieldInput } from '@/object-record/record-title-cell/components/RecordTitleCellTextFieldInput';
import { RecordTitleFullNameFieldInput } from '@/object-record/record-title-cell/components/RecordTitleFullNameFieldInput';
type RecordTitleCellFieldInputProps = {
recordFieldInputId: string;
onClickOutside?: (
persist: () => void,
event: MouseEvent | TouchEvent,
) => void;
onEnter?: FieldInputEvent;
onEscape?: FieldInputEvent;
onTab?: FieldInputEvent;
onShiftTab?: FieldInputEvent;
sizeVariant?: 'sm' | 'md';
};
export const RecordTitleCellFieldInput = ({
sizeVariant,
recordFieldInputId,
onEnter,
onEscape,
onShiftTab,
onTab,
onClickOutside,
}: RecordTitleCellFieldInputProps) => {
const { fieldDefinition } = useContext(FieldContext);
if (!isFieldText(fieldDefinition) && !isFieldFullName(fieldDefinition)) {
throw new Error('Field definition is not a text or full name field');
}
return (
<RecordFieldInputScope
recordFieldInputScopeId={getScopeIdFromComponentId(recordFieldInputId)}
>
{isFieldText(fieldDefinition) ? (
<RecordTitleCellTextFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
sizeVariant={sizeVariant}
/>
) : isFieldFullName(fieldDefinition) ? (
<RecordTitleFullNameFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
sizeVariant={sizeVariant}
/>
) : null}
</RecordFieldInputScope>
);
};

View File

@ -0,0 +1,40 @@
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { useInlineCell } from '@/object-record/record-inline-cell/hooks/useInlineCell';
import { useRecordValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import styled from '@emotion/styled';
import { useContext } from 'react';
import { OverflowingTextWithTooltip } from 'twenty-ui';
const StyledDiv = styled.div`
align-items: center;
background: inherit;
border: none;
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.font.color.primary};
cursor: pointer;
overflow: hidden;
height: 28px;
line-height: 28px;
:hover {
background: ${({ theme }) => theme.background.transparent.light};
}
`;
export const RecordTitleCellSingleTextDisplayMode = () => {
const { recordId, fieldDefinition } = useContext(FieldContext);
const recordValue = useRecordValue(recordId);
const { openInlineCell } = useInlineCell();
return (
<StyledDiv onClick={() => openInlineCell()}>
<OverflowingTextWithTooltip
text={
recordValue?.[fieldDefinition.metadata.fieldName] ||
fieldDefinition.label
}
/>
</StyledDiv>
);
};

View File

@ -0,0 +1,78 @@
import { usePersistField } from '@/object-record/record-field/hooks/usePersistField';
import { useTextField } from '@/object-record/record-field/meta-types/hooks/useTextField';
import { FieldInputClickOutsideEvent } from '@/object-record/record-field/meta-types/input/components/DateTimeFieldInput';
import { useRegisterInputEvents } from '@/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents';
import { FieldInputEvent } from '@/object-record/record-field/types/FieldInputEvent';
import { TextInputV2 } from '@/ui/input/components/TextInputV2';
import { useRef } from 'react';
import { isDefined } from 'twenty-shared';
import { turnIntoUndefinedIfWhitespacesOnly } from '~/utils/string/turnIntoUndefinedIfWhitespacesOnly';
type RecordTitleCellTextFieldInputProps = {
onClickOutside?: FieldInputClickOutsideEvent;
onEnter?: FieldInputEvent;
onEscape?: FieldInputEvent;
onTab?: FieldInputEvent;
onShiftTab?: FieldInputEvent;
sizeVariant?: 'sm' | 'md';
};
export const RecordTitleCellTextFieldInput = ({
sizeVariant,
onEnter,
onEscape,
onClickOutside,
onTab,
onShiftTab,
}: RecordTitleCellTextFieldInputProps) => {
const { fieldDefinition, draftValue, hotkeyScope, setDraftValue } =
useTextField();
const wrapperRef = useRef<HTMLInputElement>(null);
const handleChange = (newText: string) => {
setDraftValue(turnIntoUndefinedIfWhitespacesOnly(newText));
};
const persistField = usePersistField();
useRegisterInputEvents<string>({
inputRef: wrapperRef,
inputValue: draftValue ?? '',
onEnter: (inputValue) => {
onEnter?.(() => persistField(inputValue));
},
onEscape: (inputValue) => {
onEscape?.(() => persistField(inputValue));
},
onClickOutside: (event, inputValue) => {
onClickOutside?.(() => persistField(inputValue), event);
},
onTab: (inputValue) => {
onTab?.(() => persistField(inputValue));
},
onShiftTab: (inputValue) => {
onShiftTab?.(() => persistField(inputValue));
},
hotkeyScope,
});
const handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
if (isDefined(draftValue)) {
event.target.select();
}
};
return (
<TextInputV2
autoGrow
sizeVariant={sizeVariant}
inheritFontStyles
value={draftValue ?? ''}
onChange={handleChange}
placeholder={fieldDefinition.label}
onFocus={handleFocus}
autoFocus
/>
);
};

View File

@ -0,0 +1,235 @@
import styled from '@emotion/styled';
import { ClipboardEvent, useEffect, useRef, useState } from 'react';
import { Key } from 'ts-key-enum';
import { FieldDoubleText } from '@/object-record/record-field/types/FieldDoubleText';
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 { isDefined } from 'twenty-shared';
import { splitFullName } from '~/utils/format/spiltFullName';
import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly';
const StyledContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
justify-content: inherit;
width: 100%;
`;
const StyledTextInputWrapper = styled.div`
max-width: 50%;
`;
type RecordTitleDoubleTextInputProps = {
firstValue: string;
secondValue: string;
firstValuePlaceholder: string;
secondValuePlaceholder: string;
hotkeyScope: string;
onEnter: (newDoubleTextValue: FieldDoubleText) => void;
onEscape: (newDoubleTextValue: FieldDoubleText) => void;
onTab?: (newDoubleTextValue: FieldDoubleText) => void;
onShiftTab?: (newDoubleTextValue: FieldDoubleText) => void;
onClickOutside: (
event: MouseEvent | TouchEvent,
newDoubleTextValue: FieldDoubleText,
) => void;
onChange?: (newDoubleTextValue: FieldDoubleText) => void;
onPaste?: (newDoubleTextValue: FieldDoubleText) => void;
sizeVariant?: 'sm' | 'md';
};
export const RecordTitleDoubleTextInput = ({
firstValue,
secondValue,
firstValuePlaceholder,
secondValuePlaceholder,
hotkeyScope,
onClickOutside,
onEnter,
onEscape,
onShiftTab,
onTab,
onChange,
onPaste,
sizeVariant,
}: RecordTitleDoubleTextInputProps) => {
const [firstInternalValue, setFirstInternalValue] = useState(firstValue);
const [secondInternalValue, setSecondInternalValue] = useState(secondValue);
const firstValueInputRef = useRef<HTMLInputElement>(null);
const secondValueInputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setFirstInternalValue(firstValue);
setSecondInternalValue(secondValue);
}, [firstValue, secondValue]);
const handleChange = (
newFirstValue: string,
newSecondValue: string,
): void => {
setFirstInternalValue(newFirstValue);
setSecondInternalValue(newSecondValue);
onChange?.({
firstValue: newFirstValue,
secondValue: newSecondValue,
});
};
const [focusPosition, setFocusPosition] = useState<'left' | 'right'>('left');
useScopedHotkeys(
Key.Enter,
() => {
onEnter({
firstValue: firstInternalValue,
secondValue: secondInternalValue,
});
},
hotkeyScope,
[onEnter, firstInternalValue, secondInternalValue],
);
useScopedHotkeys(
[Key.Escape],
() => {
onEscape({
firstValue: firstInternalValue,
secondValue: secondInternalValue,
});
},
hotkeyScope,
[onEscape, firstInternalValue, secondInternalValue],
);
useScopedHotkeys(
'tab',
() => {
if (focusPosition === 'left') {
setFocusPosition('right');
secondValueInputRef.current?.focus();
} else {
onTab?.({
firstValue: firstInternalValue,
secondValue: secondInternalValue,
});
}
},
hotkeyScope,
[onTab, firstInternalValue, secondInternalValue, focusPosition],
);
useScopedHotkeys(
'shift+tab',
() => {
if (focusPosition === 'right') {
setFocusPosition('left');
firstValueInputRef.current?.focus();
} else {
onShiftTab?.({
firstValue: firstInternalValue,
secondValue: secondInternalValue,
});
}
},
hotkeyScope,
[onShiftTab, firstInternalValue, secondInternalValue, focusPosition],
);
useListenClickOutside({
refs: [containerRef],
callback: (event) => {
onClickOutside?.(event, {
firstValue: firstInternalValue,
secondValue: secondInternalValue,
});
},
enabled: isDefined(onClickOutside),
listenerId: 'record-title-double-text-input',
});
const handleOnPaste = (event: ClipboardEvent<HTMLInputElement>) => {
if (firstInternalValue.length > 0 || secondInternalValue.length > 0) {
return;
}
event.preventDefault();
const name = event.clipboardData.getData('Text');
const splittedName = splitFullName(name);
onPaste?.({
firstValue: splittedName[0],
secondValue: splittedName[1],
});
};
const handleClickToPreventParentClickEvents = (
event: React.MouseEvent<HTMLInputElement>,
) => {
event.stopPropagation();
event.preventDefault();
};
return (
<StyledContainer ref={containerRef}>
<StyledTextInputWrapper>
<TextInputV2
autoGrow
sizeVariant={sizeVariant}
autoComplete="off"
inheritFontStyles
autoFocus
onFocus={(event: React.FocusEvent<HTMLInputElement>) => {
if (isDefined(firstInternalValue)) {
event.target.select();
}
setFocusPosition('left');
}}
ref={firstValueInputRef}
placeholder={firstValuePlaceholder}
value={firstInternalValue}
onChange={(text: string) => {
handleChange(
turnIntoEmptyStringIfWhitespacesOnly(text),
secondInternalValue,
);
}}
onPaste={(event: ClipboardEvent<HTMLInputElement>) =>
handleOnPaste(event)
}
onClick={handleClickToPreventParentClickEvents}
/>
</StyledTextInputWrapper>
<StyledTextInputWrapper>
<TextInputV2
autoGrow
sizeVariant={sizeVariant}
autoComplete="off"
inheritFontStyles
onFocus={(event: React.FocusEvent<HTMLInputElement>) => {
if (isDefined(secondInternalValue)) {
event.target.select();
}
setFocusPosition('right');
}}
ref={secondValueInputRef}
placeholder={secondValuePlaceholder}
value={secondInternalValue}
onChange={(text: string) => {
handleChange(
firstInternalValue,
turnIntoEmptyStringIfWhitespacesOnly(text),
);
}}
onClick={handleClickToPreventParentClickEvents}
/>
</StyledTextInputWrapper>
</StyledContainer>
);
};

View File

@ -0,0 +1,43 @@
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { useFullNameFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useFullNameFieldDisplay';
import { useInlineCell } from '@/object-record/record-inline-cell/hooks/useInlineCell';
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { useContext } from 'react';
import { OverflowingTextWithTooltip } from 'twenty-ui';
const StyledDiv = styled.div`
align-items: center;
background: inherit;
border: none;
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.font.color.primary};
cursor: pointer;
overflow: hidden;
height: 28px;
line-height: 28px;
:hover {
background: ${({ theme }) => theme.background.transparent.light};
}
`;
export const RecordTitleFullNameFieldDisplay = () => {
const { fieldDefinition } = useContext(FieldContext);
const { openInlineCell } = useInlineCell();
const { fieldValue } = useFullNameFieldDisplay();
const content = [fieldValue?.firstName, fieldValue?.lastName]
.filter(isNonEmptyString)
.join(' ')
.trim();
return (
<StyledDiv onClick={() => openInlineCell()}>
<OverflowingTextWithTooltip
text={isNonEmptyString(content) ? content : fieldDefinition.label}
/>
</StyledDiv>
);
};

View File

@ -0,0 +1,102 @@
import { useFullNameField } from '@/object-record/record-field/meta-types/hooks/useFullNameField';
import {
FieldInputClickOutsideEvent,
FieldInputEvent,
} from '@/object-record/record-field/meta-types/input/components/DateTimeFieldInput';
import { FIRST_NAME_PLACEHOLDER_WITH_SPECIAL_CHARACTER_TO_AVOID_PASSWORD_MANAGERS } from '@/object-record/record-field/meta-types/input/constants/FirstNamePlaceholder';
import { LAST_NAME_PLACEHOLDER_WITH_SPECIAL_CHARACTER_TO_AVOID_PASSWORD_MANAGERS } from '@/object-record/record-field/meta-types/input/constants/LastNamePlaceholder';
import { isDoubleTextFieldEmpty } from '@/object-record/record-field/meta-types/input/utils/isDoubleTextFieldEmpty';
import { FieldDoubleText } from '@/object-record/record-field/types/FieldDoubleText';
import { RecordTitleDoubleTextInput } from './RecordTitleDoubleTextInput';
type RecordTitleFullNameFieldInputProps = {
onClickOutside?: FieldInputClickOutsideEvent;
onEnter?: FieldInputEvent;
onEscape?: FieldInputEvent;
onTab?: FieldInputEvent;
onShiftTab?: FieldInputEvent;
sizeVariant?: 'sm' | 'md';
};
export const RecordTitleFullNameFieldInput = ({
onEnter,
onEscape,
onClickOutside,
onTab,
onShiftTab,
sizeVariant,
}: RecordTitleFullNameFieldInputProps) => {
const { hotkeyScope, draftValue, setDraftValue, persistFullNameField } =
useFullNameField();
const convertToFullName = (newDoubleText: FieldDoubleText) => {
return {
firstName: newDoubleText.firstValue.trim(),
lastName: newDoubleText.secondValue.trim(),
};
};
const getRequiredDraftValueFromDoubleText = (
newDoubleText: FieldDoubleText,
) => {
return isDoubleTextFieldEmpty(newDoubleText)
? undefined
: convertToFullName(newDoubleText);
};
const handleEnter = (newDoubleText: FieldDoubleText) => {
onEnter?.(() => persistFullNameField(convertToFullName(newDoubleText)));
};
const handleEscape = (newDoubleText: FieldDoubleText) => {
onEscape?.(() => persistFullNameField(convertToFullName(newDoubleText)));
};
const handleClickOutside = (
event: MouseEvent | TouchEvent,
newDoubleText: FieldDoubleText,
) => {
onClickOutside?.(
() => persistFullNameField(convertToFullName(newDoubleText)),
event,
);
};
const handleTab = (newDoubleText: FieldDoubleText) => {
onTab?.(() => persistFullNameField(convertToFullName(newDoubleText)));
};
const handleShiftTab = (newDoubleText: FieldDoubleText) => {
onShiftTab?.(() => persistFullNameField(convertToFullName(newDoubleText)));
};
const handleChange = (newDoubleText: FieldDoubleText) => {
setDraftValue(getRequiredDraftValueFromDoubleText(newDoubleText));
};
const handlePaste = (newDoubleText: FieldDoubleText) => {
setDraftValue(getRequiredDraftValueFromDoubleText(newDoubleText));
};
return (
<RecordTitleDoubleTextInput
firstValue={draftValue?.firstName ?? ''}
secondValue={draftValue?.lastName ?? ''}
firstValuePlaceholder={
FIRST_NAME_PLACEHOLDER_WITH_SPECIAL_CHARACTER_TO_AVOID_PASSWORD_MANAGERS
}
secondValuePlaceholder={
LAST_NAME_PLACEHOLDER_WITH_SPECIAL_CHARACTER_TO_AVOID_PASSWORD_MANAGERS
}
onClickOutside={handleClickOutside}
onEnter={handleEnter}
onEscape={handleEscape}
onShiftTab={handleShiftTab}
onTab={handleTab}
onPaste={handlePaste}
hotkeyScope={hotkeyScope}
onChange={handleChange}
sizeVariant={sizeVariant}
/>
);
};

View File

@ -0,0 +1,73 @@
import { isInlineCellInEditModeScopedState } from '@/object-record/record-inline-cell/states/isInlineCellInEditModeScopedState';
import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope';
import { useGoBackToPreviousDropdownFocusId } from '@/ui/layout/dropdown/hooks/useGoBackToPreviousDropdownFocusId';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared';
export const useRecordTitleCell = () => {
const { goBackToPreviousDropdownFocusId } =
useGoBackToPreviousDropdownFocusId();
const {
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
} = usePreviousHotkeyScope();
const closeRecordTitleCell = useRecoilCallback(
({ set }) =>
({
recordId,
fieldMetadataId,
}: {
recordId: string;
fieldMetadataId: string;
}) => {
set(
isInlineCellInEditModeScopedState(recordId + fieldMetadataId),
false,
);
goBackToPreviousHotkeyScope();
goBackToPreviousDropdownFocusId();
},
[goBackToPreviousDropdownFocusId, goBackToPreviousHotkeyScope],
);
const openRecordTitleCell = useRecoilCallback(
({ set }) =>
({
recordId,
fieldMetadataId,
customEditHotkeyScopeForField,
}: {
recordId: string;
fieldMetadataId: string;
customEditHotkeyScopeForField?: HotkeyScope;
}) => {
set(
isInlineCellInEditModeScopedState(recordId + fieldMetadataId),
true,
);
if (isDefined(customEditHotkeyScopeForField)) {
setHotkeyScopeAndMemorizePreviousScope(
customEditHotkeyScopeForField.scope,
customEditHotkeyScopeForField.customScopes,
);
} else {
setHotkeyScopeAndMemorizePreviousScope(
InlineCellHotkeyScope.InlineCell,
);
}
},
[setHotkeyScopeAndMemorizePreviousScope],
);
return {
closeRecordTitleCell,
openRecordTitleCell,
};
};

View File

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