Refactor inline-cell-input hover on field-list (#13906)

This is first step toward separating the path between display mode and
input mode for inline-cells like we have done for tableCell.

The idea is to have 1 floating or anchored field input shared between
all cells. This should be done for field-list, board and task/note row +
for hover and edit mode.

This PR is only about field-list and hover
This commit is contained in:
Charles Bochet
2025-08-13 17:51:11 +02:00
committed by GitHub
parent b3ae72eb9a
commit 6528038e9e
20 changed files with 694 additions and 89 deletions
@@ -24,13 +24,13 @@ import { getActivityAttachmentIdsToDelete } from '@/activities/utils/getActivity
import { getActivityAttachmentPathsToRestore } from '@/activities/utils/getActivityAttachmentPathsToRestore';
import { SIDE_PANEL_FOCUS_ID } from '@/command-menu/constants/SidePanelFocusId';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem';
import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords';
import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords';
import { useRestoreManyRecords } from '@/object-record/hooks/useRestoreManyRecords';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useIsRecordFieldReadOnly } from '@/object-record/record-field/ui/hooks/read-only/useIsRecordFieldReadOnly';
import { isInlineCellInEditModeFamilyState } from '@/object-record/record-inline-cell/states/isInlineCellInEditModeFamilyState';
import { useRecordShowContainerData } from '@/object-record/record-show/hooks/useRecordShowContainerData';
import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType';
import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId';
import { BlockEditor } from '@/ui/input/editor/components/BlockEditor';
@@ -363,10 +363,10 @@ export const ActivityRichTextEditor = ({
dependencies: [handleAllKeys],
});
const { labelIdentifierFieldMetadataItem } = useRecordShowContainerData({
objectNameSingular: activityObjectNameSingular,
objectRecordId: activityId,
});
const { labelIdentifierFieldMetadataItem } =
useLabelIdentifierFieldMetadataItem({
objectNameSingular: activityObjectNameSingular,
});
const recordTitleCellId = getRecordFieldInputInstanceId({
recordId: activityId,
@@ -0,0 +1,56 @@
import { FieldDisplay } from '@/object-record/record-field/ui/components/FieldDisplay';
import { FieldInput } from '@/object-record/record-field/ui/components/FieldInput';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { useGetButtonIcon } from '@/object-record/record-field/ui/hooks/useGetButtonIcon';
import { useIsFieldInputOnly } from '@/object-record/record-field/ui/hooks/useIsFieldInputOnly';
import {
RecordInlineCellContext,
type RecordInlineCellContextProps,
} from '@/object-record/record-inline-cell/components/RecordInlineCellContext';
import { useContext, type ReactNode } from 'react';
import { useIcons } from 'twenty-ui/display';
type RecordFieldListCellAnchorPortalContextProps = {
children: ReactNode;
};
export const RecordFieldListCellAnchorPortalContext = ({
children,
}: RecordFieldListCellAnchorPortalContextProps) => {
const {
isRecordFieldReadOnly,
fieldDefinition,
isDisplayModeFixHeight,
onOpenEditMode,
onCloseEditMode,
isCentered,
} = useContext(FieldContext);
const buttonIcon = useGetButtonIcon();
const { getIcon } = useIcons();
const isFieldInputOnly = useIsFieldInputOnly();
const RecordInlineCellContextValue: RecordInlineCellContextProps = {
readonly: isRecordFieldReadOnly,
buttonIcon: buttonIcon,
IconLabel: fieldDefinition.iconName
? getIcon(fieldDefinition.iconName)
: undefined,
label: fieldDefinition.label,
labelWidth: fieldDefinition.labelWidth,
showLabel: fieldDefinition.showLabel,
isCentered,
editModeContent: <FieldInput />,
displayModeContent: <FieldDisplay />,
isDisplayModeFixHeight: isDisplayModeFixHeight,
editModeContentOnly: isFieldInputOnly,
loading: false,
onOpenEditMode,
onCloseEditMode,
};
return (
<RecordInlineCellContext.Provider value={RecordInlineCellContextValue}>
{children}
</RecordInlineCellContext.Provider>
);
};
@@ -0,0 +1,118 @@
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { RecordFieldListCellAnchorPortalContext } from '@/object-record/record-field-list/anchored-portal/components/RecordFieldListCellAnchorPortalContext';
import {
FieldContext,
type RecordUpdateHook,
type RecordUpdateHookParams,
} from '@/object-record/record-field/ui/contexts/FieldContext';
import { useIsRecordFieldReadOnly } from '@/object-record/record-field/ui/hooks/read-only/useIsRecordFieldReadOnly';
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext';
import { RecordInlineCellHoveredPortal } from '@/object-record/record-inline-cell/components/RecordInlineCellHoveredPortal';
import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { createPortal } from 'react-dom';
import { isDefined } from 'twenty-shared/utils';
type RecordFieldListCellAnchoredPortalProps = {
position: number;
fieldMetadataItem: Pick<
FieldMetadataItem,
'id' | 'name' | 'type' | 'createdAt' | 'updatedAt' | 'label'
>;
children: React.ReactNode;
};
export const RecordFieldListCellAnchoredPortal = ({
position,
fieldMetadataItem,
children,
}: RecordFieldListCellAnchoredPortalProps) => {
const anchorElement = document.body.querySelector<HTMLAnchorElement>(
`#record-field-list-cell-${position}`,
);
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const targetedRecordsRuleFromContextStore = useRecoilComponentValue(
contextStoreTargetedRecordsRuleComponentState,
);
let recordId;
if (targetedRecordsRuleFromContextStore.mode === 'selection') {
recordId = targetedRecordsRuleFromContextStore.selectedRecordIds[0];
}
const INPUT_ID_PREFIX = 'fields-card';
const isRecordFieldReadOnly = useIsRecordFieldReadOnly({
fieldMetadataId: fieldMetadataItem.id,
objectMetadataId: objectMetadataItem.id,
recordId: recordId ?? '',
});
const { updateOneRecord } = useUpdateOneRecord({
objectNameSingular: objectMetadataItem.nameSingular,
});
const useUpdateOneObjectRecordMutation: RecordUpdateHook = () => {
const updateEntity = ({ variables }: RecordUpdateHookParams) => {
updateOneRecord?.({
idToUpdate: variables.where.id as string,
updateOneRecordInput: variables.updateOneRecordInput,
});
};
return [updateEntity, { loading: false }];
};
if (!isDefined(anchorElement) || !isDefined(recordId)) {
return null;
}
return (
<FieldContext.Provider
key={recordId + fieldMetadataItem.id}
value={{
recordId,
maxWidth: 200,
isLabelIdentifier: false,
fieldDefinition: formatFieldMetadataItemAsColumnDefinition({
field: fieldMetadataItem,
position: 0,
objectMetadataItem,
showLabel: true,
labelWidth: 90,
}),
useUpdateRecord: useUpdateOneObjectRecordMutation,
isDisplayModeFixHeight: true,
isRecordFieldReadOnly,
}}
>
<>
{createPortal(
<RecordFieldComponentInstanceContext.Provider
value={{
instanceId: getRecordFieldInputInstanceId({
recordId,
fieldName: fieldMetadataItem.name,
prefix: INPUT_ID_PREFIX,
}),
}}
>
<RecordFieldListCellAnchorPortalContext>
<RecordInlineCellHoveredPortal>
{children}
</RecordInlineCellHoveredPortal>
</RecordFieldListCellAnchorPortalContext>
</RecordFieldComponentInstanceContext.Provider>,
anchorElement,
)}
</>
</FieldContext.Provider>
);
};
@@ -0,0 +1,39 @@
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { RecordFieldListCellAnchoredPortal } from '@/object-record/record-field-list/anchored-portal/components/RecordFieldListCellAnchoredPortal';
import { RecordFieldListCellHoveredPortalContent } from '@/object-record/record-field-list/anchored-portal/components/RecordFieldListCellHoveredPortalContent';
import { useFieldListFieldMetadataFromPosition } from '@/object-record/record-field-list/hooks/useFieldListFieldMetadataFromPosition';
import { recordFieldListHoverPositionComponentState } from '@/object-record/record-field-list/states/recordFieldListHoverPositionComponentState';
import { isDefined } from 'twenty-shared/utils';
type RecordFieldListCellHoveredPortalProps = {
objectMetadataItem: ObjectMetadataItem;
};
export const RecordFieldListCellHoveredPortal = ({
objectMetadataItem,
}: RecordFieldListCellHoveredPortalProps) => {
const hoverPosition = useRecoilComponentValue(
recordFieldListHoverPositionComponentState,
);
const { hoveredFieldMetadataItem } = useFieldListFieldMetadataFromPosition({
objectNameSingular: objectMetadataItem.nameSingular,
});
if (!isDefined(hoverPosition) || !isDefined(hoveredFieldMetadataItem)) {
return null;
}
return (
<RecordFieldListCellAnchoredPortal
position={hoverPosition}
fieldMetadataItem={hoveredFieldMetadataItem}
>
<RecordFieldListCellHoveredPortalContent
objectMetadataItem={objectMetadataItem}
/>
</RecordFieldListCellAnchoredPortal>
);
};
@@ -0,0 +1,65 @@
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useFieldListFieldMetadataFromPosition } from '@/object-record/record-field-list/hooks/useFieldListFieldMetadataFromPosition';
import { recordFieldListHoverPositionComponentState } from '@/object-record/record-field-list/states/recordFieldListHoverPositionComponentState';
import { FieldDisplay } from '@/object-record/record-field/ui/components/FieldDisplay';
import { FieldInput } from '@/object-record/record-field/ui/components/FieldInput';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { useRecordInlineCellContext } from '@/object-record/record-inline-cell/components/RecordInlineCellContext';
import { RecordInlineCellDisplayMode } from '@/object-record/record-inline-cell/components/RecordInlineCellDisplayMode';
import { RecordInlineCellHoveredPortalContent } from '@/object-record/record-inline-cell/components/RecordInlineCellHoveredPortalContent';
import { useInlineCell } from '@/object-record/record-inline-cell/hooks/useInlineCell';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
type RecordFieldListCellHoveredPortalContentProps = {
objectMetadataItem: ObjectMetadataItem;
};
export const RecordFieldListCellHoveredPortalContent = ({
objectMetadataItem,
}: RecordFieldListCellHoveredPortalContentProps) => {
const hoverPosition = useRecoilComponentValue(
recordFieldListHoverPositionComponentState,
);
const { editModeContentOnly, isCentered } = useRecordInlineCellContext();
const { hoveredFieldMetadataItem } = useFieldListFieldMetadataFromPosition({
objectNameSingular: objectMetadataItem.nameSingular,
});
const { isRecordFieldReadOnly } = useContext(FieldContext);
const { openInlineCell } = useInlineCell();
const shouldContainerBeClickable =
!isRecordFieldReadOnly && !editModeContentOnly;
const setRecordFieldListHoverPosition = useSetRecoilComponentState(
recordFieldListHoverPositionComponentState,
'fields-card',
);
const handleMouseLeave = () => {
setRecordFieldListHoverPosition(null);
};
if (!isDefined(hoverPosition) || !isDefined(hoveredFieldMetadataItem)) {
return null;
}
return (
<RecordInlineCellHoveredPortalContent
readonly={isRecordFieldReadOnly}
isCentered={isCentered}
onClick={shouldContainerBeClickable ? openInlineCell : undefined}
onMouseLeave={handleMouseLeave}
>
<RecordInlineCellDisplayMode>
{editModeContentOnly ? <FieldInput /> : <FieldDisplay />}
</RecordInlineCellDisplayMode>
</RecordInlineCellHoveredPortalContent>
);
};
@@ -0,0 +1,110 @@
import { FieldInput } from '@/object-record/record-field/ui/components/FieldInput';
import { usePersistFieldFromFieldInputContext } from '@/object-record/record-field/ui/hooks/usePersistFieldFromFieldInputContext';
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext';
import {
FieldInputEventContext,
type FieldInputClickOutsideEvent,
type FieldInputEvent,
} from '@/object-record/record-field/ui/contexts/FieldInputEventContext';
import { useRecordTableBodyContextOrThrow } from '@/object-record/record-table/contexts/RecordTableBodyContext';
import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector';
import { useAvailableComponentInstanceId } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceId';
import { useRecoilCallback } from 'recoil';
export const RecordTableCellFieldInput = () => {
const { onMoveFocus, onCloseTableCell } = useRecordTableBodyContextOrThrow();
const instanceId = useAvailableComponentInstanceId(
RecordFieldComponentInstanceContext,
);
const { persistFieldFromFieldInputContext } =
usePersistFieldFromFieldInputContext();
const handleEnter: FieldInputEvent = ({ newValue, skipPersist }) => {
if (skipPersist !== true) {
persistFieldFromFieldInputContext(newValue);
}
onCloseTableCell();
onMoveFocus('down');
};
const handleSubmit: FieldInputEvent = ({ newValue, skipPersist }) => {
if (skipPersist !== true) {
persistFieldFromFieldInputContext(newValue);
}
onCloseTableCell();
};
const handleCancel = () => {
onCloseTableCell();
};
const handleClickOutside: FieldInputClickOutsideEvent = useRecoilCallback(
({ snapshot }) =>
({ newValue, event, skipPersist }) => {
const currentFocusId = snapshot
.getLoadable(currentFocusIdSelector)
.getValue();
if (currentFocusId !== instanceId) {
return;
}
event?.preventDefault();
event?.stopImmediatePropagation();
if (skipPersist !== true) {
persistFieldFromFieldInputContext(newValue);
}
onCloseTableCell();
},
[onCloseTableCell, instanceId, persistFieldFromFieldInputContext],
);
const handleEscape: FieldInputEvent = ({ newValue, skipPersist }) => {
if (skipPersist !== true) {
persistFieldFromFieldInputContext(newValue);
}
onCloseTableCell();
};
const handleTab: FieldInputEvent = ({ newValue, skipPersist }) => {
if (skipPersist !== true) {
persistFieldFromFieldInputContext(newValue);
}
onCloseTableCell();
onMoveFocus('right');
};
const handleShiftTab: FieldInputEvent = ({ newValue, skipPersist }) => {
if (skipPersist !== true) {
persistFieldFromFieldInputContext(newValue);
}
onCloseTableCell();
onMoveFocus('left');
};
return (
<FieldInputEventContext.Provider
value={{
onCancel: handleCancel,
onEnter: handleEnter,
onEscape: handleEscape,
onClickOutside: handleClickOutside,
onShiftTab: handleShiftTab,
onSubmit: handleSubmit,
onTab: handleTab,
}}
>
<FieldInput />
</FieldInputEventContext.Provider>
);
};
@@ -0,0 +1,48 @@
import { useFieldListFieldMetadataItems } from '@/object-record/record-field-list/hooks/useFieldListFieldMetadataItems';
import { recordFieldListCellEditModePositionComponentState } from '@/object-record/record-field-list/states/recordFieldListCellEditModePositionComponentState';
import { recordFieldListHoverPositionComponentState } from '@/object-record/record-field-list/states/recordFieldListHoverPositionComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { isDefined } from 'twenty-shared/utils';
type UseFieldListFieldMetadataFromPositionProps = {
objectNameSingular: string;
};
export const useFieldListFieldMetadataFromPosition = ({
objectNameSingular,
}: UseFieldListFieldMetadataFromPositionProps) => {
const hoverPosition = useRecoilComponentValue(
recordFieldListHoverPositionComponentState,
);
const editModePosition = useRecoilComponentValue(
recordFieldListCellEditModePositionComponentState,
);
const {
inlineFieldMetadataItems,
inlineRelationFieldMetadataItems,
boxedRelationFieldMetadataItems,
} = useFieldListFieldMetadataItems({
objectNameSingular,
});
const fieldMetadataItems = [
...inlineRelationFieldMetadataItems,
...inlineFieldMetadataItems,
...boxedRelationFieldMetadataItems,
];
const hoveredFieldMetadataItem = isDefined(hoverPosition)
? fieldMetadataItems.at(hoverPosition)
: undefined;
const editedFieldMetadataItem = isDefined(editModePosition)
? fieldMetadataItems.at(editModePosition)
: undefined;
return {
hoveredFieldMetadataItem,
editedFieldMetadataItem,
};
};
@@ -0,0 +1,91 @@
import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { isFieldCellSupported } from '@/object-record/utils/isFieldCellSupported';
import groupBy from 'lodash.groupby';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
type UseFieldListFieldMetadataItemsProps = {
objectNameSingular: string;
};
export const useFieldListFieldMetadataItems = ({
objectNameSingular,
}: UseFieldListFieldMetadataItemsProps) => {
const { labelIdentifierFieldMetadataItem } =
useLabelIdentifierFieldMetadataItem({
objectNameSingular,
});
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { objectMetadataItems } = useObjectMetadataItems();
const availableFieldMetadataItems = objectMetadataItem.readableFields
.filter(
(fieldMetadataItem) =>
isFieldCellSupported(fieldMetadataItem, objectMetadataItems) &&
fieldMetadataItem.id !== labelIdentifierFieldMetadataItem?.id,
)
.sort((fieldMetadataItemA, fieldMetadataItemB) =>
fieldMetadataItemA.name.localeCompare(fieldMetadataItemB.name),
);
const { inlineFieldMetadataItems, relationFieldMetadataItems } = groupBy(
availableFieldMetadataItems
.filter(
(fieldMetadataItem) =>
fieldMetadataItem.name !== 'createdAt' &&
fieldMetadataItem.name !== 'deletedAt',
)
.filter(
(fieldMetadataItem) =>
fieldMetadataItem.type !== FieldMetadataType.RICH_TEXT_V2,
),
(fieldMetadataItem) =>
fieldMetadataItem.type === FieldMetadataType.RELATION
? 'relationFieldMetadataItems'
: 'inlineFieldMetadataItems',
);
const inlineRelationFieldMetadataItems = (
relationFieldMetadataItems ?? []
).filter(
(fieldMetadataItem) =>
(objectNameSingular === CoreObjectNameSingular.Note &&
fieldMetadataItem.name === 'noteTargets') ||
(objectNameSingular === CoreObjectNameSingular.Task &&
fieldMetadataItem.name === 'taskTargets'),
);
const boxedRelationFieldMetadataItems = (
relationFieldMetadataItems ?? []
).filter(
(fieldMetadataItem) =>
!(
(objectNameSingular === CoreObjectNameSingular.Note &&
fieldMetadataItem.name === 'noteTargets') ||
(objectNameSingular === CoreObjectNameSingular.Task &&
fieldMetadataItem.name === 'taskTargets')
) &&
isDefined(fieldMetadataItem.relation?.targetObjectMetadata.id) &&
getObjectPermissionsForObject(
objectPermissionsByObjectMetadataId,
fieldMetadataItem.relation?.targetObjectMetadata.id,
).canReadObjectRecords,
);
return {
inlineFieldMetadataItems,
inlineRelationFieldMetadataItems,
boxedRelationFieldMetadataItems,
};
};
@@ -0,0 +1,4 @@
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
export const RecordFieldListComponentInstanceContext =
createComponentInstanceContext();
@@ -0,0 +1,9 @@
import { RecordFieldListComponentInstanceContext } from '@/object-record/record-field-list/states/contexts/RecordFieldListComponentInstanceContext';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
export const recordFieldListCellEditModePositionComponentState =
createComponentState<number | null>({
key: 'recordFieldListCellEditModePositionComponentState',
defaultValue: null,
componentInstanceContext: RecordFieldListComponentInstanceContext,
});
@@ -0,0 +1,10 @@
import { RecordFieldListComponentInstanceContext } from '@/object-record/record-field-list/states/contexts/RecordFieldListComponentInstanceContext';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
export const recordFieldListHoverPositionComponentState = createComponentState<
number | null
>({
key: 'recordFieldListHoverPositionComponentState',
defaultValue: null,
componentInstanceContext: RecordFieldListComponentInstanceContext,
});
@@ -38,8 +38,11 @@ export type GenericFieldContextType = {
onRecordChipClick?: (event: MouseEvent) => void;
onOpenEditMode?: () => void;
onCloseEditMode?: () => void;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
triggerEvent?: TriggerEventType;
isForbidden?: boolean;
anchorId?: string;
};
export const FieldContext = createContext<GenericFieldContextType>(
@@ -99,7 +99,8 @@ export const RecordInlineCellContainer = () => {
const { isInlineCellInEditMode, openInlineCell } = useInlineCell();
const { recordId, fieldDefinition } = useContext(FieldContext);
const { recordId, fieldDefinition, onMouseEnter, onMouseLeave, anchorId } =
useContext(FieldContext);
const shouldContainerBeClickable =
!readonly && !editModeContentOnly && !isInlineCellInEditMode;
@@ -114,12 +115,14 @@ export const RecordInlineCellContainer = () => {
if (!readonly) {
setIsFocused(true);
}
onMouseEnter?.();
};
const handleContainerMouseLeave = () => {
if (!readonly) {
setIsFocused(false);
}
onMouseLeave?.();
};
const theme = useTheme();
@@ -164,7 +167,7 @@ export const RecordInlineCellContainer = () => {
{isInlineCellInEditMode && (
<RecordInlineCellCloseOnCommandMenuOpeningEffect />
)}
<StyledValueContainer readonly={readonly ?? false}>
<StyledValueContainer readonly={readonly ?? false} id={anchorId}>
<RecordInlineCellValue />
</StyledValueContainer>
</StyledInlineCellBaseContainer>
@@ -0,0 +1,11 @@
import styled from '@emotion/styled';
const StyledRecordTableCellHoveredPortal = styled.div`
height: 100%;
left: 0;
position: absolute;
top: 0;
width: 100%;
`;
export const RecordInlineCellHoveredPortal = StyledRecordTableCellHoveredPortal;
@@ -0,0 +1,66 @@
import { css } from '@emotion/react';
import styled from '@emotion/styled';
const StyledRecordTableCellHoveredPortalContent = styled.div<{
readonly?: boolean;
isCentered?: boolean;
}>`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
width: 100%;
${({ isCentered }) =>
isCentered === true &&
`
justify-content: center;
`};
${({ readonly }) =>
!readonly &&
css`
cursor: pointer;
`};
`;
const StyledInlineCellBaseContainer = styled.div<{ readonly: boolean }>`
box-sizing: border-box;
width: 100%;
display: flex;
height: fit-content;
gap: ${({ theme }) => theme.spacing(1)};
user-select: none;
align-items: center;
cursor: ${({ readonly }) => (readonly ? 'default' : 'pointer')};
`;
type RecordInlineCellHoveredPortalContentProps = {
children: React.ReactNode;
readonly: boolean;
isCentered?: boolean;
onClick?: () => void;
onMouseLeave?: () => void;
};
export const RecordInlineCellHoveredPortalContent = ({
children,
isCentered,
readonly,
onClick,
onMouseLeave,
}: RecordInlineCellHoveredPortalContentProps) => {
return (
<StyledInlineCellBaseContainer
readonly={readonly}
onClick={onClick}
onMouseLeave={onMouseLeave}
>
<StyledRecordTableCellHoveredPortalContent
isCentered={isCentered}
readonly={readonly}
>
{children}
</StyledRecordTableCellHoveredPortalContent>
</StyledInlineCellBaseContainer>
);
};
@@ -1,12 +1,12 @@
import groupBy from 'lodash.groupby';
import { ActivityTargetsInlineCell } from '@/activities/inline-cell/components/ActivityTargetsInlineCell';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { type CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition';
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { RecordFieldListCellHoveredPortal } from '@/object-record/record-field-list/anchored-portal/components/RecordFieldListCellHoveredPortal';
import { useFieldListFieldMetadataItems } from '@/object-record/record-field-list/hooks/useFieldListFieldMetadataItems';
import { RecordFieldListComponentInstanceContext } from '@/object-record/record-field-list/states/contexts/RecordFieldListComponentInstanceContext';
import { recordFieldListHoverPositionComponentState } from '@/object-record/record-field-list/states/recordFieldListHoverPositionComponentState';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { useIsRecordReadOnly } from '@/object-record/record-field/ui/hooks/read-only/useIsRecordReadOnly';
import { isRecordFieldReadOnly } from '@/object-record/record-field/ui/hooks/read-only/utils/isRecordFieldReadOnly';
@@ -19,11 +19,9 @@ import { useRecordShowContainerData } from '@/object-record/record-show/hooks/us
import { RecordDetailDuplicatesSection } from '@/object-record/record-show/record-detail-section/components/RecordDetailDuplicatesSection';
import { RecordDetailRelationSection } from '@/object-record/record-show/record-detail-section/components/RecordDetailRelationSection';
import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId';
import { isFieldCellSupported } from '@/object-record/utils/isFieldCellSupported';
import { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId';
import { useIsInRightDrawerOrThrow } from '@/ui/layout/right-drawer/contexts/RightDrawerContext';
import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
type FieldsCardProps = {
objectNameSingular: string;
@@ -38,16 +36,14 @@ export const FieldsCard = ({
objectRecordId,
showDuplicatesSection = true,
}: FieldsCardProps) => {
const { recordLoading, labelIdentifierFieldMetadataItem, isPrefetchLoading } =
useRecordShowContainerData({
objectNameSingular,
objectRecordId,
});
const { recordLoading, isPrefetchLoading } = useRecordShowContainerData({
objectRecordId,
});
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { objectMetadataItems } = useObjectMetadataItems();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({
@@ -57,63 +53,34 @@ export const FieldsCard = ({
const { isInRightDrawer } = useIsInRightDrawerOrThrow();
const availableFieldMetadataItems = objectMetadataItem.readableFields
.filter(
(fieldMetadataItem) =>
isFieldCellSupported(fieldMetadataItem, objectMetadataItems) &&
fieldMetadataItem.id !== labelIdentifierFieldMetadataItem?.id,
)
.sort((fieldMetadataItemA, fieldMetadataItemB) =>
fieldMetadataItemA.name.localeCompare(fieldMetadataItemB.name),
);
const { inlineFieldMetadataItems, relationFieldMetadataItems } = groupBy(
availableFieldMetadataItems
.filter(
(fieldMetadataItem) =>
fieldMetadataItem.name !== 'createdAt' &&
fieldMetadataItem.name !== 'deletedAt',
)
.filter(
(fieldMetadataItem) =>
fieldMetadataItem.type !== FieldMetadataType.RICH_TEXT_V2,
),
(fieldMetadataItem) =>
fieldMetadataItem.type === FieldMetadataType.RELATION
? 'relationFieldMetadataItems'
: 'inlineFieldMetadataItems',
);
const inlineRelationFieldMetadataItems = relationFieldMetadataItems?.filter(
(fieldMetadataItem) =>
(objectNameSingular === CoreObjectNameSingular.Note &&
fieldMetadataItem.name === 'noteTargets') ||
(objectNameSingular === CoreObjectNameSingular.Task &&
fieldMetadataItem.name === 'taskTargets'),
);
const boxedRelationFieldMetadataItems = relationFieldMetadataItems?.filter(
(fieldMetadataItem) =>
!(
(objectNameSingular === CoreObjectNameSingular.Note &&
fieldMetadataItem.name === 'noteTargets') ||
(objectNameSingular === CoreObjectNameSingular.Task &&
fieldMetadataItem.name === 'taskTargets')
) &&
isDefined(fieldMetadataItem.relation?.targetObjectMetadata.id) &&
getObjectPermissionsForObject(
objectPermissionsByObjectMetadataId,
fieldMetadataItem.relation?.targetObjectMetadata.id,
).canReadObjectRecords,
);
const isRecordReadOnly = useIsRecordReadOnly({
recordId: objectRecordId,
objectMetadataId: objectMetadataItem.id,
});
const setRecordFieldListHoverPosition = useSetRecoilComponentState(
recordFieldListHoverPositionComponentState,
'fields-card',
);
const handleMouseEnter = (index: number) => {
setRecordFieldListHoverPosition(index);
};
const {
inlineFieldMetadataItems,
inlineRelationFieldMetadataItems,
boxedRelationFieldMetadataItems,
} = useFieldListFieldMetadataItems({
objectNameSingular,
});
return (
<>
<RecordFieldListComponentInstanceContext.Provider
value={{
instanceId: 'fields-card',
}}
>
<PropertyBox>
{isPrefetchLoading ? (
<PropertyBoxSkeletonLoader />
@@ -200,6 +167,13 @@ export const FieldsCard = ({
fieldType: fieldMetadataItem.type,
isCustom: fieldMetadataItem.isCustom ?? false,
}),
onMouseEnter: () =>
handleMouseEnter(
index + (inlineRelationFieldMetadataItems?.length ?? 0),
),
anchorId: `record-field-list-cell-${
index + (inlineRelationFieldMetadataItems?.length ?? 0)
}`,
}}
>
<RecordFieldComponentInstanceContext.Provider
@@ -259,6 +233,10 @@ export const FieldsCard = ({
/>
</FieldContext.Provider>
))}
</>
<RecordFieldListCellHoveredPortal
objectMetadataItem={objectMetadataItem}
/>
</RecordFieldListComponentInstanceContext.Provider>
);
};
@@ -36,7 +36,6 @@ export const RecordShowContainer = ({
});
const { isPrefetchLoading, recordLoading } = useRecordShowContainerData({
objectNameSingular,
objectRecordId,
});
@@ -1,4 +1,5 @@
import { useGetStandardObjectIcon } from '@/object-metadata/hooks/useGetStandardObjectIcon';
import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
@@ -27,11 +28,9 @@ export const SummaryCard = ({
objectRecordId,
isInRightDrawer,
}: SummaryCardProps) => {
const { recordLoading, labelIdentifierFieldMetadataItem, isPrefetchLoading } =
useRecordShowContainerData({
objectNameSingular,
objectRecordId,
});
const { recordLoading, isPrefetchLoading } = useRecordShowContainerData({
objectRecordId,
});
const recordCreatedAt = useRecoilValue<string | null>(
recordStoreFamilySelector({
@@ -60,6 +59,11 @@ export const SummaryCard = ({
objectNameSingular,
});
const { labelIdentifierFieldMetadataItem } =
useLabelIdentifierFieldMetadataItem({
objectNameSingular,
});
const isTitleReadOnly = useIsRecordFieldReadOnly({
recordId: objectRecordId,
fieldMetadataId: labelIdentifierFieldMetadataItem?.id ?? '',
@@ -1,22 +1,14 @@
import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem';
import { recordLoadingFamilyState } from '@/object-record/record-store/states/recordLoadingFamilyState';
import { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading';
import { useRecoilState } from 'recoil';
type UseRecordShowContainerDataProps = {
objectNameSingular: string;
objectRecordId: string;
};
export const useRecordShowContainerData = ({
objectNameSingular,
objectRecordId,
}: UseRecordShowContainerDataProps) => {
const { labelIdentifierFieldMetadataItem } =
useLabelIdentifierFieldMetadataItem({
objectNameSingular,
});
const [recordLoading] = useRecoilState(
recordLoadingFamilyState(objectRecordId),
);
@@ -25,7 +17,6 @@ export const useRecordShowContainerData = ({
return {
recordLoading,
labelIdentifierFieldMetadataItem,
isPrefetchLoading,
};
};
@@ -9,7 +9,6 @@ import { RecordFilterGroupsComponentInstanceContext } from '@/object-record/reco
import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext';
import { RecordShowContainer } from '@/object-record/record-show/components/RecordShowContainer';
import { RecordShowEffect } from '@/object-record/record-show/components/RecordShowEffect';
import { useRecordShowPage } from '@/object-record/record-show/hooks/useRecordShowPage';
import { computeRecordShowComponentInstanceId } from '@/object-record/record-show/utils/computeRecordShowComponentInstanceId';
import { RecordSortsComponentInstanceContext } from '@/object-record/record-sort/states/context/RecordSortsComponentInstanceContext';
import { PageHeaderToggleCommandMenuButton } from '@/ui/layout/page-header/components/PageHeaderToggleCommandMenuButton';
@@ -17,6 +16,7 @@ import { PageBody } from '@/ui/layout/page/components/PageBody';
import { PageContainer } from '@/ui/layout/page/components/PageContainer';
import { RecordShowPageHeader } from '~/pages/object-record/RecordShowPageHeader';
import { RecordShowPageTitle } from '~/pages/object-record/RecordShowPageTitle';
import { useRecordShowPage } from '@/object-record/record-show/hooks/useRecordShowPage';
export const RecordShowPage = () => {
const parameters = useParams<{