From 2044be28ec2d08aef9a61d9e6bb81d902033fff1 Mon Sep 17 00:00:00 2001 From: Lucas Bordeau Date: Wed, 10 Sep 2025 11:47:54 +0200 Subject: [PATCH] Refactored z-index handling for hovered portal with a better UX (#14384) This PR closes the first step of https://github.com/twentyhq/core-team-issues/issues/1451, about z-index fine-tuning on the table. The core part was to succeed in having the hovered portal and the surrounding cells switch their z-index to have the borders overlap nicely in all scrolling cases. See associated video for the new behavior. This PR fixes two majors UX issues we had : - The sticky column and row were sliding a bit on the beginning of the scroll - Too many components were re-rendering while we only needed to isolate those who presented a complex use case In the end, we isolated some very specific components like the cell 0-0 and the first scrollable header cell, which were giving the most problems with the hovered portal. This allows to leave the rest of the cells and header cells alone with those concerns because they weren't involved at all in z-index update, once we isolated those components. ## Demo https://github.com/user-attachments/assets/cbe630ed-63c3-4e86-a22e-a11662c6082c https://github.com/user-attachments/assets/36e6947d-9f89-4ed2-ba6c-cbb13016d7d1 --- .../components/RecordTableContent.tsx | 103 +++++----- .../RecordTableScrollAndZIndexEffect.tsx | 71 +++++++ .../RecordTableStickyBottomEffect.tsx | 3 - .../components/RecordTableStickyEffect.tsx | 36 ---- .../record-table/constants/TableZIndex.ts | 60 +++--- .../components/RecordTableBody.tsx | 31 +-- .../components/RecordTableBodyDroppable.tsx | 14 +- .../components/RecordTableBodyLoading.tsx | 5 +- .../RecordTableCellBaseContainer.tsx | 3 +- .../components/RecordTableCellEditMode.tsx | 2 - .../RecordTableCellEditModePortal.tsx | 17 +- .../RecordTableCellFirstRowFirstColumn.tsx | 69 +++++++ .../RecordTableCellHoveredPortal.tsx | 169 +++++----------- .../RecordTableCellHoveredPortalContent.tsx | 85 ++++++++ .../RecordTableCellPortalContexts.tsx | 74 +++++++ .../RecordTableCellPortalRootContainer.tsx | 15 ++ .../RecordTableCellPortalWrapper.tsx | 63 +----- .../components/RecordTableCellPortals.tsx | 1 - .../components/RecordTableTd.tsx | 4 +- .../components/RecordTableColumnHead.tsx | 13 +- .../components/RecordTableHeader.tsx | 21 +- .../components/RecordTableHeaderCell.tsx | 179 ++--------------- .../RecordTableHeaderFirstScrollableCell.tsx | 173 ++++++++++++++++ .../RecordTableHeaderLabelIdentifierCell.tsx | 188 ++++++++++++++++++ .../RecordTableHeaderResizeHandler.tsx | 42 ++++ .../hooks/useResizeTableHeader.ts | 110 ++++++++++ .../components/RecordTableCellsVisible.tsx | 29 ++- .../components/RecordTableTr.tsx | 9 +- ...TableScrolledHorizontallyComponentState.ts | 2 +- ...rdTableScrolledVerticallyComponentState.ts | 2 +- .../resizedFieldMetadataIdComponentState.ts | 11 + packages/twenty-shared/src/types/Nullable.ts | 1 + packages/twenty-shared/src/types/index.ts | 1 + 33 files changed, 1081 insertions(+), 525 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-table/components/RecordTableScrollAndZIndexEffect.tsx delete mode 100644 packages/twenty-front/src/modules/object-record/record-table/components/RecordTableStickyBottomEffect.tsx delete mode 100644 packages/twenty-front/src/modules/object-record/record-table/components/RecordTableStickyEffect.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellFirstRowFirstColumn.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellHoveredPortalContent.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellPortalContexts.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellPortalRootContainer.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderFirstScrollableCell.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderLabelIdentifierCell.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderResizeHandler.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-header/hooks/useResizeTableHeader.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-table/states/resizedFieldMetadataIdComponentState.ts create mode 100644 packages/twenty-shared/src/types/Nullable.ts diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableContent.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableContent.tsx index 9d40f65001..89a8114d9a 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableContent.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableContent.tsx @@ -1,25 +1,21 @@ -import { RecordTableStickyBottomEffect } from '@/object-record/record-table/components/RecordTableStickyBottomEffect'; -import { RecordTableStickyEffect } from '@/object-record/record-table/components/RecordTableStickyEffect'; +import { RecordTableScrollAndZIndexEffect } from '@/object-record/record-table/components/RecordTableScrollAndZIndexEffect'; import { TABLE_Z_INDEX } from '@/object-record/record-table/constants/TableZIndex'; import { RecordTableNoRecordGroupBody } from '@/object-record/record-table/record-table-body/components/RecordTableNoRecordGroupBody'; import { RecordTableRecordGroupsBody } from '@/object-record/record-table/record-table-body/components/RecordTableRecordGroupsBody'; import { RecordTableHeader } from '@/object-record/record-table/record-table-header/components/RecordTableHeader'; import { isRowSelectedComponentFamilyState } from '@/object-record/record-table/record-table-row/states/isRowSelectedComponentFamilyState'; -import { isRecordTableScrolledHorizontallyComponentState } from '@/object-record/record-table/states/isRecordTableScrolledHorizontallyComponentState'; -import { isRecordTableScrolledVerticallyComponentState } from '@/object-record/record-table/states/isRecordTableScrolledVerticallyComponentState'; import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect'; import { RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS } from '@/ui/utilities/drag-select/constants/RecordIndecDragSelectBoundaryClass'; import { useRecoilComponentFamilyCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyCallbackState'; -import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import styled from '@emotion/styled'; import { useRef, useState } from 'react'; import { useRecoilCallback } from 'recoil'; import { MOBILE_VIEWPORT } from 'twenty-ui/theme'; -const StyledTableWithPointerEvents = styled.div<{ +export const RECORD_TABLE_HTML_ID = 'record-table'; + +const StyledTable = styled.div<{ isDragging: boolean; - stickyColumnZIndex: number; - normalColumnZIndex: number; }>` & > * { pointer-events: ${({ isDragging }) => (isDragging ? 'none' : 'auto')}; @@ -33,35 +29,38 @@ const StyledTableWithPointerEvents = styled.div<{ top: 0; } - div.header-cell:nth-of-type(n + 3) { - z-index: ${({ normalColumnZIndex }) => normalColumnZIndex}; + div.header-cell:nth-of-type(n + 5) { + z-index: ${TABLE_Z_INDEX.headerColumnsNormal}; } div.header-cell:nth-of-type(1) { - // position: sticky; left: 0px; - z-index: ${({ stickyColumnZIndex }) => stickyColumnZIndex}; + transition: 0.3s ease; background-color: ${({ theme }) => theme.background.primary}; + + z-index: ${TABLE_Z_INDEX.headerColumnsSticky}; } div.header-cell:nth-of-type(2) { - // position: sticky; left: 16px; top: 0; - z-index: ${({ stickyColumnZIndex }) => stickyColumnZIndex}; + transition: 0.3s ease; background-color: ${({ theme }) => theme.background.primary}; + + z-index: ${TABLE_Z_INDEX.headerColumnsSticky}; } div.header-cell:nth-of-type(3) { - // position: sticky; left: 48px; right: 0; - z-index: ${({ stickyColumnZIndex }) => stickyColumnZIndex}; + transition: 0.3s ease; background-color: ${({ theme }) => theme.background.primary}; + z-index: ${TABLE_Z_INDEX.headerColumnsSticky}; + // &::after { // content: ''; // position: absolute; @@ -80,6 +79,40 @@ const StyledTableWithPointerEvents = styled.div<{ } } + // TODO: re-implement horizontal scroll here after table have been refactored to divs + div.table-cell:nth-of-type(1) { + position: sticky; + left: 0px; + z-index: ${TABLE_Z_INDEX.cell.sticky}; + } + + div.table-cell:nth-of-type(2) { + position: sticky; + left: 16px; + z-index: ${TABLE_Z_INDEX.cell.sticky}; + } + + div.table-cell-0-0 { + position: sticky; + left: 48px; + + @media (max-width: ${MOBILE_VIEWPORT}px) { + width: ${38}px; + max-width: ${38}px; + } + } + + div.table-cell:nth-of-type(3) { + position: sticky; + left: 48px; + z-index: ${TABLE_Z_INDEX.cell.sticky}; + + @media (max-width: ${MOBILE_VIEWPORT}px) { + width: ${38}px; + max-width: ${38}px; + } + } + div.footer-cell:nth-of-type(n + 3) { z-index: ${TABLE_Z_INDEX.footer.default}; @@ -148,39 +181,14 @@ export const RecordTableContent = ({ [isRowSelectedCallbackFamilyState], ); - const isRecordTableScrolledHorizontally = useRecoilComponentValue( - isRecordTableScrolledHorizontallyComponentState, - ); - - const isRecordTableScrolledVertically = useRecoilComponentValue( - isRecordTableScrolledVerticallyComponentState, - ); - - const computedStickyColumnZIndex = - isRecordTableScrolledHorizontally && isRecordTableScrolledVertically - ? TABLE_Z_INDEX.scrolledBothVerticallyAndHorizontally.headerColumnsSticky - : isRecordTableScrolledHorizontally - ? TABLE_Z_INDEX.scrolledHorizontallyOnly.headerColumnsSticky - : isRecordTableScrolledVertically - ? TABLE_Z_INDEX.scrolledVerticallyOnly.headerColumnsSticky - : TABLE_Z_INDEX.noScrollAtAll.headerColumnsSticky; - - const computedNormalColumnZIndex = - isRecordTableScrolledHorizontally && isRecordTableScrolledVertically - ? TABLE_Z_INDEX.scrolledBothVerticallyAndHorizontally.headerColumnsNormal - : isRecordTableScrolledHorizontally - ? TABLE_Z_INDEX.scrolledHorizontallyOnly.headerColumnsNormal - : isRecordTableScrolledVertically - ? TABLE_Z_INDEX.scrolledVerticallyOnly.headerColumnsNormal - : TABLE_Z_INDEX.noScrollAtAll.headerColumnsNormal; + const recordTableScrollWrapperId = `record-table-scroll-${recordTableId}`; return ( - {hasRecordGroups ? ( @@ -188,15 +196,14 @@ export const RecordTableContent = ({ ) : ( )} - - - + + diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableScrollAndZIndexEffect.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableScrollAndZIndexEffect.tsx new file mode 100644 index 0000000000..9cc57c6604 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableScrollAndZIndexEffect.tsx @@ -0,0 +1,71 @@ +import { isRecordTableScrolledHorizontallyComponentState } from '@/object-record/record-table/states/isRecordTableScrolledHorizontallyComponentState'; +import { isRecordTableScrolledVerticallyComponentState } from '@/object-record/record-table/states/isRecordTableScrolledVerticallyComponentState'; + +import { useScrollWrapperElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperElement'; +import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; + +import { useEffect, useState } from 'react'; +import { isDefined } from 'twenty-shared/utils'; + +export const RecordTableScrollAndZIndexEffect = () => { + const { scrollWrapperHTMLElement } = useScrollWrapperElement(); + + const setIsRecordTableScrolledHorizontally = useSetRecoilComponentState( + isRecordTableScrolledHorizontallyComponentState, + ); + + const setIsRecordTableScrolledVertically = useSetRecoilComponentState( + isRecordTableScrolledVerticallyComponentState, + ); + + const [isScrolledVertically, setIsScrolledVertically] = useState(false); + const [isScrolledHorizontally, setIsScrolledHorizontally] = useState(false); + + useEffect(() => { + if (!isDefined(scrollWrapperHTMLElement)) { + return; + } + + const handleScroll = (event: any) => { + const target = event.currentTarget; + + let somethingHasChanged = false; + + const newIsScrolledVertically = target?.scrollTop > 0; + + if (newIsScrolledVertically !== isScrolledVertically) { + setIsScrolledVertically(newIsScrolledVertically); + setIsRecordTableScrolledVertically(newIsScrolledVertically); + somethingHasChanged = true; + } + + const newIsScrolledHorizontally = target?.scrollLeft > 0; + + if (newIsScrolledHorizontally !== isScrolledHorizontally) { + setIsScrolledHorizontally(newIsScrolledHorizontally); + setIsRecordTableScrolledHorizontally(newIsScrolledHorizontally); + somethingHasChanged = true; + } + + if (!somethingHasChanged) { + return; + } + + // TODO: insert imperative CSS update here + }; + + scrollWrapperHTMLElement?.addEventListener('scroll', handleScroll); + + return () => { + scrollWrapperHTMLElement?.removeEventListener('scroll', handleScroll); + }; + }, [ + scrollWrapperHTMLElement, + isScrolledVertically, + isScrolledHorizontally, + setIsRecordTableScrolledVertically, + setIsRecordTableScrolledHorizontally, + ]); + + return <>; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableStickyBottomEffect.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableStickyBottomEffect.tsx deleted file mode 100644 index a0f744beac..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableStickyBottomEffect.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export const RecordTableStickyBottomEffect = () => { - return <>; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableStickyEffect.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableStickyEffect.tsx deleted file mode 100644 index a06a937885..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableStickyEffect.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useEffect } from 'react'; - -import { isRecordTableScrolledHorizontallyComponentState } from '@/object-record/record-table/states/isRecordTableScrolledHorizontallyComponentState'; -import { isRecordTableScrolledVerticallyComponentState } from '@/object-record/record-table/states/isRecordTableScrolledVerticallyComponentState'; -import { scrollWrapperScrollLeftComponentState } from '@/ui/utilities/scroll/states/scrollWrapperScrollLeftComponentState'; -import { scrollWrapperScrollTopComponentState } from '@/ui/utilities/scroll/states/scrollWrapperScrollTopComponentState'; -import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; -import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; - -export const RecordTableStickyEffect = () => { - const scrollTop = useRecoilComponentValue( - scrollWrapperScrollTopComponentState, - ); - - const setIsRecordTableScrolledVertically = useSetRecoilComponentState( - isRecordTableScrolledVerticallyComponentState, - ); - - useEffect(() => { - setIsRecordTableScrolledVertically(scrollTop > 0); - }, [scrollTop, setIsRecordTableScrolledVertically]); - - const scrollLeft = useRecoilComponentValue( - scrollWrapperScrollLeftComponentState, - ); - - const setIsRecordTableScrolledHorizontally = useSetRecoilComponentState( - isRecordTableScrolledHorizontallyComponentState, - ); - - useEffect(() => { - setIsRecordTableScrolledHorizontally(scrollLeft > 0); - }, [scrollLeft, setIsRecordTableScrolledHorizontally]); - - return <>; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/constants/TableZIndex.ts b/packages/twenty-front/src/modules/object-record/record-table/constants/TableZIndex.ts index ce735819d0..0ef114ba5c 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/constants/TableZIndex.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/constants/TableZIndex.ts @@ -1,37 +1,41 @@ export const TABLE_Z_INDEX = { base: 1, cell: { - default: 3, - sticky: 10, - editMode: 20, + default: 8, + sticky: 12, + editMode: 30, }, + headerColumnsSticky: 14, + headerColumnsNormal: 10, + firstCellWithoutVerticalScroll: 15, + firstCellWithVerticalScroll: 12, + noScrollAtAll: { + hoverPortalCellOnFirstScrollableColumn: 17, + hoverPortalCellOnNormalColumn: 17, + hoverPortalCellOnLabelIdentifierColumn: 17, + firstScrollableHeaderCell: 12, + }, + scrolledBothVerticallyAndHorizontally: { + hoverPortalCellOnNormalColumn: 2, + hoverPortalCellOnFirstScrollableColumn: 2, + hoverPortalCellOnLabelIdentifierColumn: 2, + firstScrollableHeaderCell: 12, + }, + scrolledHorizontallyOnly: { + hoverPortalCellOnLabelIdentifierColumn: 17, + hoverPortalCellOnNormalColumn: 11, + hoverPortalCellOnFirstScrollableColumn: 11, + firstScrollableHeaderCell: 10, + }, + scrolledVerticallyOnly: { + hoverPortalCellOnNormalColumn: 9, + hoverPortalCellOnFirstScrollableColumn: 13, + hoverPortalCellOnLabelIdentifierColumn: 13, + firstScrollableHeaderCell: 14, + }, + columnGrip: 30, footer: { default: 18, stickyColumn: 20, }, - noScrollAtAll: { - hoverPortalCell: 17, - headerRow: 13, - headerColumnsSticky: 16, - headerColumnsNormal: 15, - }, - scrolledBothVerticallyAndHorizontally: { - headerColumnsSticky: 13, - headerColumnsNormal: 12, - headerRow: 11, - hoverPortalCell: 2, - }, - scrolledHorizontallyOnly: { - headerColumnsSticky: 13, - headerColumnsNormal: 12, - headerRow: 11, - hoverPortalCell: 9, - }, - scrolledVerticallyOnly: { - headerColumnsSticky: 14, - headerColumnsNormal: 13, - headerRow: 12, - hoverPortalCell: 11, - }, - columnGrip: 30, }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBody.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBody.tsx index 43356d772f..00595fe97d 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBody.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBody.tsx @@ -1,35 +1,8 @@ import styled from '@emotion/styled'; -import { TABLE_Z_INDEX } from '@/object-record/record-table/constants/TableZIndex'; -import { MOBILE_VIEWPORT } from 'twenty-ui/theme'; - -const StyledTbody = styled.div` - // TODO: re-implement horizontal scroll here after table have been refactored to divs - div.table-cell:nth-of-type(1) { - position: sticky; - left: 0px; - z-index: ${TABLE_Z_INDEX.cell.sticky}; - } - - div.table-cell:nth-of-type(2) { - position: sticky; - left: 16px; - z-index: ${TABLE_Z_INDEX.cell.sticky}; - } - - div.table-cell:nth-of-type(3) { - position: sticky; - left: 48px; - z-index: ${TABLE_Z_INDEX.cell.sticky}; - - @media (max-width: ${MOBILE_VIEWPORT}px) { - width: ${38}px; - max-width: ${38}px; - } - } - +const StyledTableBody = styled.div` display: flex; flex-wrap: wrap; `; -export const RecordTableBody = StyledTbody; +export const RecordTableBody = StyledTableBody; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyDroppable.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyDroppable.tsx index 0593f5e6f3..24c8e89530 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyDroppable.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyDroppable.tsx @@ -1,6 +1,8 @@ import { RecordTableBody } from '@/object-record/record-table/record-table-body/components/RecordTableBody'; import { RecordTableBodyDroppableContextProvider } from '@/object-record/record-table/record-table-body/contexts/RecordTableBodyDroppableContext'; import { recordTableHoverPositionComponentState } from '@/object-record/record-table/states/recordTableHoverPositionComponentState'; +import { isSomeCellInEditModeComponentSelector } from '@/object-record/record-table/states/selectors/isSomeCellInEditModeComponentSelector'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; import { Droppable } from '@hello-pangea/dnd'; import { type ReactNode, useState } from 'react'; @@ -24,6 +26,16 @@ export const RecordTableBodyDroppable = ({ recordTableHoverPositionComponentState, ); + const isSomeCellInEditMode = useRecoilComponentValue( + isSomeCellInEditModeComponentSelector, + ); + + const handleMouseLeave = () => { + if (!isSomeCellInEditMode) { + setRecordTableHoverPosition(null); + } + }; + return ( setRecordTableHoverPosition(null)} + onMouseLeave={handleMouseLeave} > { const { visibleRecordFields } = useRecordTableContextOrThrow(); return ( - + {Array.from({ length: 8 }).map((_, rowIndex) => ( { ))} - + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellBaseContainer.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellBaseContainer.tsx index fdd195906c..033913d879 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellBaseContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellBaseContainer.tsx @@ -24,9 +24,10 @@ const StyledBaseContainer = styled.div<{ cursor: ${({ isReadOnly }) => (isReadOnly ? 'default' : 'pointer')}; display: flex; height: 32px; - position: relative; user-select: none; + position: relative; + &.focus-active { border-radius: ${BORDER_COMMON.radius.sm}; outline: 1px solid ${({ borderColorBlue }) => borderColorBlue}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellEditMode.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellEditMode.tsx index c02a012137..4c0752676a 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellEditMode.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellEditMode.tsx @@ -3,7 +3,6 @@ import { RecordFieldComponentInstanceContext } from '@/object-record/record-fiel import { recordFieldInputIsFieldInErrorComponentState } from '@/object-record/record-field/ui/states/recordFieldInputIsFieldInErrorComponentState'; import { recordFieldInputLayoutDirectionComponentState } from '@/object-record/record-field/ui/states/recordFieldInputLayoutDirectionComponentState'; import { recordFieldInputLayoutDirectionLoadingComponentState } from '@/object-record/record-field/ui/states/recordFieldInputLayoutDirectionLoadingComponentState'; -import { TABLE_Z_INDEX } from '@/object-record/record-table/constants/TableZIndex'; import { RecordTableCellContext } from '@/object-record/record-table/contexts/RecordTableCellContext'; import { useFocusRecordTableCell } from '@/object-record/record-table/record-table-cell/hooks/useFocusRecordTableCell'; import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer'; @@ -28,7 +27,6 @@ const StyledEditableCellEditModeContainer = styled.div<{ height: 100%; position: absolute; width: calc(100% + 2px); - z-index: ${TABLE_Z_INDEX.cell.editMode}; `; const StyledInputModeOnlyContainer = styled.div` diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellEditModePortal.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellEditModePortal.tsx index ac55d52a4a..018e2a73d8 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellEditModePortal.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellEditModePortal.tsx @@ -1,20 +1,13 @@ import { RecordTableCellPortalWrapper } from '@/object-record/record-table/record-table-cell/components/RecordTableCellPortalWrapper'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { TABLE_Z_INDEX } from '@/object-record/record-table/constants/TableZIndex'; import { RecordTableCellEditMode } from '@/object-record/record-table/record-table-cell/components/RecordTableCellEditMode'; import { RecordTableCellFieldInput } from '@/object-record/record-table/record-table-cell/components/RecordTableCellFieldInput'; import { RecordTableCellHotkeysEffect } from '@/object-record/record-table/record-table-cell/components/RecordTableCellHotkeysEffect'; +import { RecordTableCellPortalRootContainer } from '@/object-record/record-table/record-table-cell/components/RecordTableCellPortalRootContainer'; import { recordTableCellEditModePositionComponentState } from '@/object-record/record-table/states/recordTableCellEditModePositionComponentState'; import { recordTableFocusPositionComponentState } from '@/object-record/record-table/states/recordTableFocusPositionComponentState'; -import styled from '@emotion/styled'; - -const StyledRecordTableCellHoveredPortal = styled.div` - height: 100%; - left: 0; - position: absolute; - top: 0; - width: 100%; -`; export const RecordTableCellEditModePortal = () => { const focusedCellPosition = useRecoilComponentValue( @@ -32,11 +25,13 @@ export const RecordTableCellEditModePortal = () => { return ( {currentTableCellInEditModePosition && ( - + - + )} diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellFirstRowFirstColumn.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellFirstRowFirstColumn.tsx new file mode 100644 index 0000000000..7d6a097ebb --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellFirstRowFirstColumn.tsx @@ -0,0 +1,69 @@ +import { TABLE_Z_INDEX } from '@/object-record/record-table/constants/TableZIndex'; +import { + DEFAULT_RECORD_TABLE_TD_WIDTH, + StyledTd, +} from '@/object-record/record-table/record-table-cell/components/RecordTableTd'; +import { isRecordTableScrolledVerticallyComponentState } from '@/object-record/record-table/states/isRecordTableScrolledVerticallyComponentState'; +import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState'; +import styled from '@emotion/styled'; +import { type DraggableProvidedDragHandleProps } from '@hello-pangea/dnd'; +import { useContext, type ReactNode } from 'react'; +import { ThemeContext } from 'twenty-ui/theme'; + +const StyledRecordTableTd = styled(StyledTd)<{ zIndex: number }>` + z-index: ${({ zIndex }) => zIndex}; +`; + +export const RecordTableCellFirstRowFirstColumn = ({ + children, + isSelected, + isDragging, + hasRightBorder = true, + hasBottomBorder = true, + width = DEFAULT_RECORD_TABLE_TD_WIDTH, + ...dragHandleProps +}: { + className?: string; + children?: ReactNode; + isSelected?: boolean; + isDragging?: boolean; + hasRightBorder?: boolean; + hasBottomBorder?: boolean; + width?: number; +} & (Partial | null)) => { + const { theme } = useContext(ThemeContext); + + const [isRecordTableScrolledVertically] = useRecoilComponentState( + isRecordTableScrolledVerticallyComponentState, + ); + + const zIndex = isRecordTableScrolledVertically + ? TABLE_Z_INDEX.firstCellWithVerticalScroll + : TABLE_Z_INDEX.firstCellWithoutVerticalScroll; + + const tdBackgroundColor = isSelected + ? theme.accent.quaternary + : theme.background.primary; + + const borderColor = theme.border.color.light; + + const fontColor = theme.font.color.primary; + + return ( + + {children} + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellHoveredPortal.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellHoveredPortal.tsx index 75db4572cb..9e8f43d3f0 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellHoveredPortal.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellHoveredPortal.tsx @@ -1,137 +1,78 @@ import { RecordTableCellPortalWrapper } from '@/object-record/record-table/record-table-cell/components/RecordTableCellPortalWrapper'; import { recordTableHoverPositionComponentState } from '@/object-record/record-table/states/recordTableHoverPositionComponentState'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; -import styled from '@emotion/styled'; -import { FieldDisplay } from '@/object-record/record-field/ui/components/FieldDisplay'; -import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext'; -import { useIsFieldInputOnly } from '@/object-record/record-field/ui/hooks/useIsFieldInputOnly'; import { TABLE_Z_INDEX } from '@/object-record/record-table/constants/TableZIndex'; -import { useRecordTableRowContextOrThrow } from '@/object-record/record-table/contexts/RecordTableRowContext'; -import { RecordTableCellDisplayMode } from '@/object-record/record-table/record-table-cell/components/RecordTableCellDisplayMode'; -import { RecordTableCellEditButton } from '@/object-record/record-table/record-table-cell/components/RecordTableCellEditButton'; -import { RecordTableCellEditMode } from '@/object-record/record-table/record-table-cell/components/RecordTableCellEditMode'; -import { RecordTableCellFieldInput } from '@/object-record/record-table/record-table-cell/components/RecordTableCellFieldInput'; -import { isRecordTableRowActiveComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowActiveComponentFamilyState'; +import { RecordTableCellHoveredPortalContent } from '@/object-record/record-table/record-table-cell/components/RecordTableCellHoveredPortalContent'; +import { RecordTableCellPortalRootContainer } from '@/object-record/record-table/record-table-cell/components/RecordTableCellPortalRootContainer'; import { isRecordTableScrolledHorizontallyComponentState } from '@/object-record/record-table/states/isRecordTableScrolledHorizontallyComponentState'; import { isRecordTableScrolledVerticallyComponentState } from '@/object-record/record-table/states/isRecordTableScrolledVerticallyComponentState'; -import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue'; -import { useContext } from 'react'; -import { BORDER_COMMON } from 'twenty-ui/theme'; -import { useIsMobile } from 'twenty-ui/utilities'; - -const StyledRecordTableCellHoveredPortalContent = styled.div<{ - isReadOnly: boolean; - isRowActive: boolean; - zIndex: number; -}>` - align-items: center; - background: ${({ theme }) => theme.background.transparent.secondary}; - background-color: ${({ theme, isRowActive }) => - isRowActive ? theme.accent.quaternary : theme.background.primary}; - border-radius: ${({ isReadOnly }) => - !isReadOnly ? BORDER_COMMON.radius.sm : 'none'}; - box-sizing: border-box; - cursor: ${({ isReadOnly }) => (isReadOnly ? 'default' : 'pointer')}; - display: flex; - - height: 32px; - - outline: ${({ theme, isReadOnly, isRowActive }) => - isRowActive - ? 'none' - : isReadOnly - ? `1px solid ${theme.border.color.medium}` - : `1px solid ${theme.font.color.extraLight}`}; - - position: relative; - user-select: none; - - z-index: ${({ zIndex }) => zIndex}; -`; - -const RecordTableCellHoveredPortalContent = () => { - const hoverPosition = useRecoilComponentValue( - recordTableHoverPositionComponentState, - ); - - const isMobile = useIsMobile(); - - const isFirstColumn = hoverPosition?.column === 0; - - const { isRecordFieldReadOnly: isReadOnly } = useContext(FieldContext); - - const isFieldInputOnly = useIsFieldInputOnly(); - - const showButton = - !isFieldInputOnly && !isReadOnly && !(isMobile && isFirstColumn); - - const { rowIndex } = useRecordTableRowContextOrThrow(); - - const isRowActive = useRecoilComponentFamilyValue( - isRecordTableRowActiveComponentFamilyState, - rowIndex, - ); - - const isRecordTableScrolledHorizontally = useRecoilComponentValue( - isRecordTableScrolledHorizontallyComponentState, - ); - - const isRecordTableScrolledVertically = useRecoilComponentValue( - isRecordTableScrolledVerticallyComponentState, - ); - - const computedZIndex = - isRecordTableScrolledHorizontally && isRecordTableScrolledVertically - ? TABLE_Z_INDEX.scrolledBothVerticallyAndHorizontally.hoverPortalCell - : isRecordTableScrolledHorizontally - ? TABLE_Z_INDEX.scrolledHorizontallyOnly.hoverPortalCell - : isRecordTableScrolledVertically - ? TABLE_Z_INDEX.scrolledVerticallyOnly.hoverPortalCell - : TABLE_Z_INDEX.noScrollAtAll.hoverPortalCell; - - return ( - - {isFieldInputOnly ? ( - - - - ) : ( - - - - )} - {showButton && } - - ); -}; - -const StyledRecordTableCellHoveredPortal = styled.div` - height: 100%; - left: 0; - position: absolute; - top: 0; - width: 100%; -`; export const RecordTableCellHoveredPortal = () => { const hoverPosition = useRecoilComponentValue( recordTableHoverPositionComponentState, ); + const isRecordTableScrolledVertically = useRecoilComponentValue( + isRecordTableScrolledVerticallyComponentState, + ); + + const isRecordTableScrolledHorizontally = useRecoilComponentValue( + isRecordTableScrolledHorizontallyComponentState, + ); + if (!hoverPosition) { return null; } + const isOnFirstScrollableColumn = hoverPosition.column === 1; + const isOnLabelIdentifierStickyColumn = hoverPosition.column === 0; + + const zIndexForFirstScrollableColumn = + isRecordTableScrolledHorizontally && isRecordTableScrolledVertically + ? TABLE_Z_INDEX.scrolledBothVerticallyAndHorizontally + .hoverPortalCellOnFirstScrollableColumn + : isRecordTableScrolledHorizontally + ? TABLE_Z_INDEX.scrolledHorizontallyOnly + .hoverPortalCellOnFirstScrollableColumn + : isRecordTableScrolledVertically + ? TABLE_Z_INDEX.scrolledVerticallyOnly + .hoverPortalCellOnFirstScrollableColumn + : TABLE_Z_INDEX.noScrollAtAll.hoverPortalCellOnFirstScrollableColumn; + + const zIndexForLabelIdentifierStickyColumn = + isRecordTableScrolledHorizontally && isRecordTableScrolledVertically + ? TABLE_Z_INDEX.scrolledBothVerticallyAndHorizontally + .hoverPortalCellOnLabelIdentifierColumn + : isRecordTableScrolledHorizontally + ? TABLE_Z_INDEX.scrolledHorizontallyOnly + .hoverPortalCellOnLabelIdentifierColumn + : isRecordTableScrolledVertically + ? TABLE_Z_INDEX.scrolledVerticallyOnly + .hoverPortalCellOnLabelIdentifierColumn + : TABLE_Z_INDEX.noScrollAtAll.hoverPortalCellOnLabelIdentifierColumn; + + const zIndexForNormalColumn = + isRecordTableScrolledHorizontally && isRecordTableScrolledVertically + ? TABLE_Z_INDEX.scrolledBothVerticallyAndHorizontally + .hoverPortalCellOnNormalColumn + : isRecordTableScrolledHorizontally + ? TABLE_Z_INDEX.scrolledHorizontallyOnly.hoverPortalCellOnNormalColumn + : isRecordTableScrolledVertically + ? TABLE_Z_INDEX.scrolledVerticallyOnly.hoverPortalCellOnNormalColumn + : TABLE_Z_INDEX.noScrollAtAll.hoverPortalCellOnNormalColumn; + + const zIndex = isOnFirstScrollableColumn + ? zIndexForFirstScrollableColumn + : isOnLabelIdentifierStickyColumn + ? zIndexForLabelIdentifierStickyColumn + : zIndexForNormalColumn; + return ( - + - + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellHoveredPortalContent.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellHoveredPortalContent.tsx new file mode 100644 index 0000000000..352187df4c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellHoveredPortalContent.tsx @@ -0,0 +1,85 @@ +import { FieldDisplay } from '@/object-record/record-field/ui/components/FieldDisplay'; +import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext'; +import { useIsFieldInputOnly } from '@/object-record/record-field/ui/hooks/useIsFieldInputOnly'; +import { useRecordTableRowContextOrThrow } from '@/object-record/record-table/contexts/RecordTableRowContext'; +import { RecordTableCellDisplayMode } from '@/object-record/record-table/record-table-cell/components/RecordTableCellDisplayMode'; +import { RecordTableCellEditButton } from '@/object-record/record-table/record-table-cell/components/RecordTableCellEditButton'; +import { RecordTableCellEditMode } from '@/object-record/record-table/record-table-cell/components/RecordTableCellEditMode'; +import { RecordTableCellFieldInput } from '@/object-record/record-table/record-table-cell/components/RecordTableCellFieldInput'; + +import { isRecordTableRowActiveComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowActiveComponentFamilyState'; +import { recordTableHoverPositionComponentState } from '@/object-record/record-table/states/recordTableHoverPositionComponentState'; +import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import styled from '@emotion/styled'; +import { useContext } from 'react'; +import { BORDER_COMMON } from 'twenty-ui/theme'; +import { useIsMobile } from 'twenty-ui/utilities'; + +const StyledRecordTableCellHoveredPortalContent = styled.div<{ + isReadOnly: boolean; + isRowActive: boolean; +}>` + align-items: center; + background: ${({ theme }) => theme.background.transparent.secondary}; + background-color: ${({ theme, isRowActive }) => + isRowActive ? theme.accent.quaternary : theme.background.primary}; + border-radius: ${({ isReadOnly }) => + !isReadOnly ? BORDER_COMMON.radius.sm : 'none'}; + box-sizing: border-box; + cursor: ${({ isReadOnly }) => (isReadOnly ? 'default' : 'pointer')}; + display: flex; + + height: 32px; + + outline: ${({ theme, isReadOnly, isRowActive }) => + isRowActive + ? 'none' + : isReadOnly + ? `1px solid ${theme.border.color.medium}` + : `1px solid ${theme.font.color.extraLight}`}; + + user-select: none; +`; + +export const RecordTableCellHoveredPortalContent = () => { + const hoverPosition = useRecoilComponentValue( + recordTableHoverPositionComponentState, + ); + + const isMobile = useIsMobile(); + + const isFirstColumn = hoverPosition?.column === 0; + + const { isRecordFieldReadOnly: isReadOnly } = useContext(FieldContext); + + const isFieldInputOnly = useIsFieldInputOnly(); + + const showButton = + !isFieldInputOnly && !isReadOnly && !(isMobile && isFirstColumn); + + const { rowIndex } = useRecordTableRowContextOrThrow(); + + const isRowActive = useRecoilComponentFamilyValue( + isRecordTableRowActiveComponentFamilyState, + rowIndex, + ); + + return ( + + {isFieldInputOnly ? ( + + + + ) : ( + + + + )} + {showButton && } + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellPortalContexts.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellPortalContexts.tsx new file mode 100644 index 0000000000..81142766ca --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellPortalContexts.tsx @@ -0,0 +1,74 @@ +import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow'; +import { getBasePathToShowPage } from '@/object-metadata/utils/getBasePathToShowPage'; +import { useIsRecordReadOnly } from '@/object-record/read-only/hooks/useIsRecordReadOnly'; +import { visibleRecordFieldsComponentSelector } from '@/object-record/record-field/states/visibleRecordFieldsComponentSelector'; +import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector'; +import { RecordTableCellContext } from '@/object-record/record-table/contexts/RecordTableCellContext'; +import { RecordTableRowContextProvider } from '@/object-record/record-table/contexts/RecordTableRowContext'; +import { RecordTableCellFieldContextWrapper } from '@/object-record/record-table/record-table-cell/components/RecordTableCellFieldContextWrapper'; +import { recordTableHoverPositionComponentState } from '@/object-record/record-table/states/recordTableHoverPositionComponentState'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { isDefined } from 'twenty-shared/utils'; + +export const RecordTableCellPortalContexts = ({ + children, +}: { + children: React.ReactNode; +}) => { + const hoverPosition = useRecoilComponentValue( + recordTableHoverPositionComponentState, + ); + + const allRecordIds = useRecoilComponentValue( + recordIndexAllRecordIdsComponentSelector, + ); + + const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow(); + + const visibleRecordFields = useRecoilComponentValue( + visibleRecordFieldsComponentSelector, + ); + + const recordId = isDefined(hoverPosition) + ? allRecordIds.at(hoverPosition.row) + : null; + + const isRecordReadOnly = useIsRecordReadOnly({ + recordId: recordId ?? '', + objectMetadataId: objectMetadataItem.id, + }); + + if (!hoverPosition || !isDefined(recordId)) { + return null; + } + + return ( + + + + {children} + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellPortalRootContainer.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellPortalRootContainer.tsx new file mode 100644 index 0000000000..0a5d952230 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellPortalRootContainer.tsx @@ -0,0 +1,15 @@ +import styled from '@emotion/styled'; + +const StyledRecordTableCellPortalRootContainer = styled.div<{ + zIndex?: number; +}>` + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; + z-index: ${({ zIndex }) => zIndex ?? 'auto'}; +`; + +export const RecordTableCellPortalRootContainer = + StyledRecordTableCellPortalRootContainer; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellPortalWrapper.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellPortalWrapper.tsx index 71893c5f5c..6d89924a40 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellPortalWrapper.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellPortalWrapper.tsx @@ -1,13 +1,5 @@ -import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow'; -import { getBasePathToShowPage } from '@/object-metadata/utils/getBasePathToShowPage'; -import { useIsRecordReadOnly } from '@/object-record/read-only/hooks/useIsRecordReadOnly'; -import { visibleRecordFieldsComponentSelector } from '@/object-record/record-field/states/visibleRecordFieldsComponentSelector'; -import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector'; -import { RecordTableCellContext } from '@/object-record/record-table/contexts/RecordTableCellContext'; -import { RecordTableRowContextProvider } from '@/object-record/record-table/contexts/RecordTableRowContext'; -import { RecordTableCellFieldContextWrapper } from '@/object-record/record-table/record-table-cell/components/RecordTableCellFieldContextWrapper'; +import { RecordTableCellPortalContexts } from '@/object-record/record-table/record-table-cell/components/RecordTableCellPortalContexts'; import { type TableCellPosition } from '@/object-record/record-table/types/TableCellPosition'; -import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { createPortal } from 'react-dom'; import { isDefined } from 'twenty-shared/utils'; @@ -18,62 +10,21 @@ export const RecordTableCellPortalWrapper = ({ position: TableCellPosition; children: React.ReactNode; }) => { - const anchorElement = document.body.querySelector( + const tableCellAnchorElement = document.body.querySelector( `#record-table-cell-${position.column}-${position.row}`, ); - const allRecordIds = useRecoilComponentValue( - recordIndexAllRecordIdsComponentSelector, - ); - - const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow(); - - const visibleRecordFields = useRecoilComponentValue( - visibleRecordFieldsComponentSelector, - ); - - const recordId = allRecordIds.at(position.row); - - const isRecordReadOnly = useIsRecordReadOnly({ - recordId: recordId ?? '', - objectMetadataId: objectMetadataItem.id, - }); - - if (!isDefined(anchorElement) || !isDefined(recordId)) { + if (!isDefined(tableCellAnchorElement)) { return null; } return ( <> {createPortal( - - - - {children} - - - , - anchorElement, + + {children} + , + tableCellAnchorElement, )} ); diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellPortals.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellPortals.tsx index fa4d31d6e8..d9242014e6 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellPortals.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellPortals.tsx @@ -16,7 +16,6 @@ export const RecordTableCellPortals = () => { return ( <> - {isRecordTableFocusActive && ( <> diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableTd.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableTd.tsx index 034227018d..29060bebd4 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableTd.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableTd.tsx @@ -5,7 +5,7 @@ import { ThemeContext } from 'twenty-ui/theme'; export const DEFAULT_RECORD_TABLE_TD_WIDTH = 32; -const StyledTd = styled.div<{ +export const StyledTd = styled.div<{ backgroundColor: string; borderColor: string; isDragging?: boolean; @@ -32,8 +32,6 @@ const StyledTd = styled.div<{ background: ${({ backgroundColor, isDragging }) => isDragging ? 'transparent' : backgroundColor}; - - // TODO: reimplement horizontal scroll here once we have refactored body with divs `; export const RecordTableTd = ({ diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableColumnHead.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableColumnHead.tsx index dbe7bac4bd..c6b6f0aa69 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableColumnHead.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableColumnHead.tsx @@ -1,12 +1,9 @@ import { css, useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { isRecordTableScrolledHorizontallyComponentState } from '@/object-record/record-table/states/isRecordTableScrolledHorizontallyComponentState'; - import { fieldMetadataItemByIdSelector } from '@/object-metadata/states/fieldMetadataItemByIdSelector'; import { isFieldMetadataItemLabelIdentifierSelector } from '@/object-metadata/states/isFieldMetadataItemLabelIdentifierSelector'; import { type RecordField } from '@/object-record/record-field/types/RecordField'; -import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { useRecoilValue } from 'recoil'; import { useIcons } from 'twenty-ui/display'; import { MOBILE_VIEWPORT } from 'twenty-ui/theme'; @@ -61,10 +58,6 @@ export const RecordTableColumnHead = ({ }), ); - const isRecordTableScrolledHorizontally = useRecoilComponentValue( - isRecordTableScrolledHorizontallyComponentState, - ); - const { getIcon } = useIcons(); const Icon = getIcon( correspondingFieldMetadataItem.foundFieldMetadataItem?.icon, @@ -76,10 +69,10 @@ export const RecordTableColumnHead = ({ }), ); + // TODO: fix previous behavior with isRecordTableScrolledHorizontally + return ( - + diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeader.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeader.tsx index 73f40907a3..176df3742b 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeader.tsx @@ -1,20 +1,39 @@ +import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; import { RecordTableHeaderAddColumnButton } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderAddColumnButton'; import { RecordTableHeaderCell } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderCell'; import { RecordTableHeaderCheckboxColumn } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderCheckboxColumn'; import { RecordTableHeaderDragDropColumn } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderDragDropColumn'; +import { RecordTableHeaderFirstScrollableCell } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderFirstScrollableCell'; +import { RecordTableHeaderLabelIdentifierCell } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderLabelIdentifierCell'; import { RecordTableHeaderLastColumn } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderLastColumn'; +import { useResizeTableHeader } from '@/object-record/record-table/record-table-header/hooks/useResizeTableHeader'; +import { filterOutByProperty } from 'twenty-shared/utils'; export const FIRST_TH_WIDTH = '10px'; export const RecordTableHeader = () => { const { visibleRecordFields } = useRecordTableContextOrThrow(); + const { labelIdentifierFieldMetadataItem } = useRecordIndexContextOrThrow(); + + const recordFieldsWithoutLabelIdentifierAndFirstOne = visibleRecordFields + .filter( + filterOutByProperty( + 'fieldMetadataItemId', + labelIdentifierFieldMetadataItem?.id, + ), + ) + .slice(1); + + useResizeTableHeader(); return ( <> - {visibleRecordFields.map((recordField) => ( + + + {recordFieldsWithoutLabelIdentifierAndFirstOne.map((recordField) => ( theme.spacing(2)}; - position: absolute; - right: -9px; - top: 0; - width: 3px; - z-index: 1; -`; - const StyledColumnHeadContainer = styled.div` display: flex; flex-direction: row; @@ -101,10 +76,6 @@ const StyledColumnHeadContainer = styled.div` } `; -const StyledHeaderIcon = styled.div` - margin: ${({ theme }) => theme.spacing(1, 1, 1, 1.5)}; -`; - type RecordTableHeaderCellProps = { recordField: RecordField; }; @@ -112,116 +83,7 @@ type RecordTableHeaderCellProps = { export const RecordTableHeaderCell = ({ recordField, }: RecordTableHeaderCellProps) => { - const { objectMetadataItem, objectPermissions } = - useRecordTableContextOrThrow(); - - const resizeFieldOffsetState = useRecoilComponentCallbackState( - resizeFieldOffsetComponentState, - ); - - const [resizeFieldOffset, setResizeFieldOffset] = useRecoilComponentState( - resizeFieldOffsetComponentState, - ); - - const [initialPointerPositionX, setInitialPointerPositionX] = useState< - number | null - >(null); - const [resizedFieldMetadataItemId, setResizedFieldMetadataItemId] = useState< - string | null - >(null); - - const { saveRecordFields } = useSaveRecordFields(); - - const { updateRecordField } = useUpdateRecordField(); - - const handleResizeHandlerStart = useCallback( - ({ x }) => { - setInitialPointerPositionX(x); - }, - [], - ); - - const [iconVisibility, setIconVisibility] = useState(false); - - const handleResizeHandlerMove = useCallback( - ({ x }) => { - if (!initialPointerPositionX) return; - setResizeFieldOffset(x - initialPointerPositionX); - }, - [setResizeFieldOffset, initialPointerPositionX], - ); - - const handleResizeHandlerEnd = useRecoilCallback( - ({ snapshot, set }) => - async () => { - throwIfNotDefined(recordField, 'recordField'); - - if (!resizedFieldMetadataItemId) return; - - const resizeFieldOffset = getSnapshotValue( - snapshot, - resizeFieldOffsetState, - ); - - const nextWidth = Math.round( - Math.max(recordField.size + resizeFieldOffset, COLUMN_MIN_WIDTH), - ); - - set(resizeFieldOffsetState, 0); - setInitialPointerPositionX(null); - setResizedFieldMetadataItemId(null); - - if (nextWidth !== recordField.size) { - const updatedRecordField = updateRecordField( - resizedFieldMetadataItemId, - { - size: nextWidth, - }, - ); - - saveRecordFields([updatedRecordField]); - } - }, - [ - recordField, - saveRecordFields, - resizedFieldMetadataItemId, - resizeFieldOffsetState, - setResizedFieldMetadataItemId, - updateRecordField, - ], - ); - - useTrackPointer({ - shouldTrackPointer: resizedFieldMetadataItemId !== null, - onMouseDown: handleResizeHandlerStart, - onMouseMove: handleResizeHandlerMove, - onMouseUp: handleResizeHandlerEnd, - }); - - const isMobile = useIsMobile(); - - const { labelIdentifierFieldMetadataItem } = useRecordIndexContextOrThrow(); - - const isLabelIdentifier = - recordField.fieldMetadataItemId === labelIdentifierFieldMetadataItem?.id; - - const disableColumnResize = isLabelIdentifier || isMobile; - - const { createNewIndexRecord } = useCreateNewIndexRecord({ - objectMetadataItem, - }); - - const handlePlusButtonClick = () => { - createNewIndexRecord(); - }; - - const isReadOnly = isObjectMetadataReadOnly({ - objectPermissions, - objectMetadataItem, - }); - - const hasObjectUpdatePermissions = objectPermissions.canUpdateObjectRecords; + const { objectMetadataItem } = useRecordTableContextOrThrow(); const isFirstRowActive = useRecoilComponentFamilyValue( isRecordTableRowActiveComponentFamilyState, @@ -233,6 +95,14 @@ export const RecordTableHeaderCell = ({ 0, ); + const resizeFieldOffset = useRecoilComponentValue( + resizeFieldOffsetComponentState, + ); + + const resizedFieldMetadataItemId = useRecoilComponentValue( + resizedFieldMetadataIdComponentState, + ); + const widthOffsetWhileResizing = resizedFieldMetadataItemId === recordField.fieldMetadataItemId ? resizeFieldOffset @@ -254,8 +124,6 @@ export const RecordTableHeaderCell = ({ resizedFieldMetadataItemId === recordField.fieldMetadataItemId } columnWidth={columnWidth} - onMouseEnter={() => setIconVisibility(true)} - onMouseLeave={() => setIconVisibility(false)} isFirstRowActiveOrFocused={isFirstRowActiveOrFocused} > @@ -263,29 +131,8 @@ export const RecordTableHeaderCell = ({ recordField={recordField} objectMetadataId={objectMetadataItem.id} /> - {(useIsMobile() || iconVisibility) && - !!isLabelIdentifier && - !isReadOnly && - hasObjectUpdatePermissions && ( - - - - )} - {!disableColumnResize && ( - { - setResizedFieldMetadataItemId(recordField.fieldMetadataItemId); - }} - /> - )} + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderFirstScrollableCell.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderFirstScrollableCell.tsx new file mode 100644 index 0000000000..3290e46764 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderFirstScrollableCell.tsx @@ -0,0 +1,173 @@ +import styled from '@emotion/styled'; + +import { type RecordField } from '@/object-record/record-field/types/RecordField'; +import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; +import { TABLE_Z_INDEX } from '@/object-record/record-table/constants/TableZIndex'; +import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; +import { RecordTableColumnHeadWithDropdown } from '@/object-record/record-table/record-table-header/components/RecordTableColumnHeadWithDropdown'; +import { RecordTableHeaderResizeHandler } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderResizeHandler'; +import { COLUMN_RESIZE_MIN_WIDTH } from '@/object-record/record-table/record-table-header/hooks/useResizeTableHeader'; +import { isRecordTableRowActiveComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowActiveComponentFamilyState'; +import { isRecordTableRowFocusedComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowFocusedComponentFamilyState'; +import { isRecordTableScrolledHorizontallyComponentState } from '@/object-record/record-table/states/isRecordTableScrolledHorizontallyComponentState'; +import { isRecordTableScrolledVerticallyComponentState } from '@/object-record/record-table/states/isRecordTableScrolledVerticallyComponentState'; +import { resizedFieldMetadataIdComponentState } from '@/object-record/record-table/states/resizedFieldMetadataIdComponentState'; +import { resizeFieldOffsetComponentState } from '@/object-record/record-table/states/resizeFieldOffsetComponentState'; +import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { filterOutByProperty } from 'twenty-shared/utils'; + +// TODO: factorize duplicated code here +const StyledColumnHeaderCell = styled.div<{ + columnWidth: number; + isResizing?: boolean; + isFirstRowActiveOrFocused: boolean; + zIndex: number; +}>` + color: ${({ theme }) => theme.font.color.tertiary}; + padding: 0; + text-align: left; + + height: 32px; + max-height: 32px; + + background-color: ${({ theme }) => theme.background.primary}; + border-right: 1px solid ${({ theme }) => theme.border.color.light}; + + border-bottom: 1px solid ${({ theme }) => theme.border.color.light}; + + ${({ columnWidth }) => ` + min-width: ${columnWidth}px; + width: ${columnWidth}px; + `} + user-select: none; + ${({ theme }) => { + return ` + &:hover { + background: ${theme.background.secondary}; + }; + &:active { + background: ${theme.background.tertiary}; + }; + `; + }}; + ${({ isResizing, theme }) => { + if (isResizing === true) { + return `&:after { + background-color: ${theme.color.blue}; + bottom: 0; + content: ''; + display: block; + position: absolute; + right: -1px; + top: 0; + width: 2px; + }`; + } + }}; + + // TODO: refactor this, each component should own its CSS + div { + overflow: hidden; + } + + z-index: ${({ zIndex }) => zIndex}; +`; + +const StyledColumnHeadContainer = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; + + & > :first-of-type { + flex: 1; + } +`; + +export const RecordTableHeaderFirstScrollableCell = () => { + const { objectMetadataItem, visibleRecordFields } = + useRecordTableContextOrThrow(); + + const isFirstRowActive = useRecoilComponentFamilyValue( + isRecordTableRowActiveComponentFamilyState, + 0, + ); + + const isFirstRowFocused = useRecoilComponentFamilyValue( + isRecordTableRowFocusedComponentFamilyState, + 0, + ); + + const { labelIdentifierFieldMetadataItem } = useRecordIndexContextOrThrow(); + + const recordField = visibleRecordFields.filter( + filterOutByProperty( + 'fieldMetadataItemId', + labelIdentifierFieldMetadataItem?.id, + ), + )[0] as RecordField | undefined; + + const resizeFieldOffset = useRecoilComponentValue( + resizeFieldOffsetComponentState, + ); + + const resizedFieldMetadataItemId = useRecoilComponentValue( + resizedFieldMetadataIdComponentState, + ); + + const widthOffsetWhileResizing = + resizedFieldMetadataItemId === recordField?.fieldMetadataItemId + ? resizeFieldOffset + : 0; + + const baseWidth = recordField?.size ?? 0; + + const computedDynamicWidth = baseWidth + widthOffsetWhileResizing; + + const columnWidth = Math.max(computedDynamicWidth, COLUMN_RESIZE_MIN_WIDTH); + + const isFirstRowActiveOrFocused = isFirstRowActive || isFirstRowFocused; + + const isRecordTableScrolledVertically = useRecoilComponentValue( + isRecordTableScrolledVerticallyComponentState, + ); + + const isRecordTableScrolledHorizontally = useRecoilComponentValue( + isRecordTableScrolledHorizontallyComponentState, + ); + + const zIndex = + isRecordTableScrolledHorizontally && isRecordTableScrolledVertically + ? TABLE_Z_INDEX.scrolledBothVerticallyAndHorizontally + .firstScrollableHeaderCell + : isRecordTableScrolledHorizontally + ? TABLE_Z_INDEX.scrolledHorizontallyOnly.firstScrollableHeaderCell + : isRecordTableScrolledVertically + ? TABLE_Z_INDEX.scrolledVerticallyOnly.firstScrollableHeaderCell + : TABLE_Z_INDEX.noScrollAtAll.firstScrollableHeaderCell; + + if (!recordField) { + return <>; + } + + return ( + + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderLabelIdentifierCell.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderLabelIdentifierCell.tsx new file mode 100644 index 0000000000..9bc43b03da --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderLabelIdentifierCell.tsx @@ -0,0 +1,188 @@ +import styled from '@emotion/styled'; +import { useState } from 'react'; + +import { isObjectMetadataReadOnly } from '@/object-record/read-only/utils/isObjectMetadataReadOnly'; + +import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; +import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; +import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord'; +import { RecordTableColumnHeadWithDropdown } from '@/object-record/record-table/record-table-header/components/RecordTableColumnHeadWithDropdown'; +import { RecordTableHeaderResizeHandler } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderResizeHandler'; +import { COLUMN_RESIZE_MIN_WIDTH } from '@/object-record/record-table/record-table-header/hooks/useResizeTableHeader'; +import { isRecordTableRowActiveComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowActiveComponentFamilyState'; +import { isRecordTableRowFocusedComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowFocusedComponentFamilyState'; +import { resizedFieldMetadataIdComponentState } from '@/object-record/record-table/states/resizedFieldMetadataIdComponentState'; +import { resizeFieldOffsetComponentState } from '@/object-record/record-table/states/resizeFieldOffsetComponentState'; +import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; +import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { findByProperty } from 'twenty-shared/utils'; +import { IconPlus } from 'twenty-ui/display'; +import { LightIconButton } from 'twenty-ui/input'; + +// TODO: factorize duplicated code here +const StyledColumnHeaderCell = styled.div<{ + columnWidth: number; + isResizing?: boolean; + isFirstRowActiveOrFocused: boolean; +}>` + color: ${({ theme }) => theme.font.color.tertiary}; + padding: 0; + text-align: left; + + height: 32px; + max-height: 32px; + + background-color: ${({ theme }) => theme.background.primary}; + border-right: 1px solid ${({ theme }) => theme.border.color.light}; + + border-bottom: 1px solid ${({ theme }) => theme.border.color.light}; + + ${({ columnWidth }) => ` + min-width: ${columnWidth}px; + width: ${columnWidth}px; + `} + user-select: none; + ${({ theme }) => { + return ` + &:hover { + background: ${theme.background.secondary}; + }; + &:active { + background: ${theme.background.tertiary}; + }; + `; + }}; + ${({ isResizing, theme }) => { + if (isResizing === true) { + return `&:after { + background-color: ${theme.color.blue}; + bottom: 0; + content: ''; + display: block; + position: absolute; + right: -1px; + top: 0; + width: 2px; + }`; + } + }}; + + // TODO: refactor this, each component should own its CSS + div { + overflow: hidden; + } +`; + +const StyledColumnHeadContainer = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; + + & > :first-of-type { + flex: 1; + } +`; + +const StyledHeaderIcon = styled.div` + margin: ${({ theme }) => theme.spacing(1, 1, 1, 1.5)}; +`; + +export const RecordTableHeaderLabelIdentifierCell = () => { + const { objectMetadataItem, objectPermissions, visibleRecordFields } = + useRecordTableContextOrThrow(); + + const [iconIsVisible, setIconIsVisible] = useState(false); + + const isMobile = useIsMobile(); + + const isFirstRowActive = useRecoilComponentFamilyValue( + isRecordTableRowActiveComponentFamilyState, + 0, + ); + + const isFirstRowFocused = useRecoilComponentFamilyValue( + isRecordTableRowFocusedComponentFamilyState, + 0, + ); + + const { labelIdentifierFieldMetadataItem } = useRecordIndexContextOrThrow(); + + const recordField = visibleRecordFields.find( + findByProperty('fieldMetadataItemId', labelIdentifierFieldMetadataItem?.id), + ); + + const { createNewIndexRecord } = useCreateNewIndexRecord({ + objectMetadataItem, + }); + + const resizeFieldOffset = useRecoilComponentValue( + resizeFieldOffsetComponentState, + ); + + const resizedFieldMetadataItemId = useRecoilComponentValue( + resizedFieldMetadataIdComponentState, + ); + + if (!recordField) { + return <>; + } + + const handlePlusButtonClick = () => { + createNewIndexRecord(); + }; + + const isReadOnly = isObjectMetadataReadOnly({ + objectPermissions, + objectMetadataItem, + }); + + const hasObjectUpdatePermissions = objectPermissions.canUpdateObjectRecords; + + const widthOffsetWhileResizing = + resizedFieldMetadataItemId === recordField.fieldMetadataItemId + ? resizeFieldOffset + : 0; + + const baseWidth = recordField?.size ?? 0; + + const computedDynamicWidth = baseWidth + widthOffsetWhileResizing; + + const columnWidth = Math.max(computedDynamicWidth, COLUMN_RESIZE_MIN_WIDTH); + + const isFirstRowActiveOrFocused = isFirstRowActive || isFirstRowFocused; + + return ( + setIconIsVisible(true)} + onMouseLeave={() => setIconIsVisible(false)} + isFirstRowActiveOrFocused={isFirstRowActiveOrFocused} + > + + + {(isMobile || iconIsVisible) && + !isReadOnly && + hasObjectUpdatePermissions && ( + + + + )} + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderResizeHandler.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderResizeHandler.tsx new file mode 100644 index 0000000000..b5fb05ebba --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderResizeHandler.tsx @@ -0,0 +1,42 @@ +import { type RecordField } from '@/object-record/record-field/types/RecordField'; +import { resizedFieldMetadataIdComponentState } from '@/object-record/record-table/states/resizedFieldMetadataIdComponentState'; +import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; +import styled from '@emotion/styled'; +import { useIsMobile } from 'twenty-ui/utilities'; + +const StyledResizeHandler = styled.div` + bottom: 0; + cursor: col-resize; + padding: 0 ${({ theme }) => theme.spacing(2)}; + position: absolute; + right: -9px; + top: 0; + width: 3px; + z-index: 1; +`; + +export const RecordTableHeaderResizeHandler = ({ + recordField, +}: { + recordField: RecordField; +}) => { + const isMobile = useIsMobile(); + + const columnResizeDisabled = isMobile; + + const setResizedFieldMetadataItemId = useSetRecoilComponentState( + resizedFieldMetadataIdComponentState, + ); + + return ( + !columnResizeDisabled && ( + { + setResizedFieldMetadataItemId(recordField.fieldMetadataItemId); + }} + /> + ) + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/hooks/useResizeTableHeader.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/hooks/useResizeTableHeader.ts new file mode 100644 index 0000000000..0a1b6af91f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/hooks/useResizeTableHeader.ts @@ -0,0 +1,110 @@ +import { useUpdateRecordField } from '@/object-record/record-field/hooks/useUpdateRecordField'; +import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; +import { resizedFieldMetadataIdComponentState } from '@/object-record/record-table/states/resizedFieldMetadataIdComponentState'; +import { resizeFieldOffsetComponentState } from '@/object-record/record-table/states/resizeFieldOffsetComponentState'; +import { useTrackPointer } from '@/ui/utilities/pointer-event/hooks/useTrackPointer'; +import { type PointerEventListener } from '@/ui/utilities/pointer-event/types/PointerEventListener'; +import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; +import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState'; +import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; +import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; +import { useSaveRecordFields } from '@/views/hooks/useSaveRecordFields'; +import { useCallback, useState } from 'react'; +import { useRecoilCallback } from 'recoil'; +import { findByProperty, throwIfNotDefined } from 'twenty-shared/utils'; + +export const COLUMN_RESIZE_MIN_WIDTH = 48; + +export const useResizeTableHeader = () => { + const { visibleRecordFields } = useRecordTableContextOrThrow(); + + const resizeFieldOffsetCallbackState = useRecoilComponentCallbackState( + resizeFieldOffsetComponentState, + ); + + const setResizeFieldOffset = useSetRecoilComponentState( + resizeFieldOffsetComponentState, + ); + + const [initialPointerPositionX, setInitialPointerPositionX] = useState< + number | null + >(null); + + const [resizedFieldMetadataItemId, setResizedFieldMetadataItemId] = + useRecoilComponentState(resizedFieldMetadataIdComponentState); + + const { saveRecordFields } = useSaveRecordFields(); + + const { updateRecordField } = useUpdateRecordField(); + + const handleResizeHandlerStart = useCallback( + ({ x }) => { + setInitialPointerPositionX(x); + }, + [], + ); + + const handleResizeHandlerMove = useCallback( + ({ x }) => { + if (!initialPointerPositionX) return; + + setResizeFieldOffset(x - initialPointerPositionX); + }, + [setResizeFieldOffset, initialPointerPositionX], + ); + + const handleResizeHandlerEnd = useRecoilCallback( + ({ snapshot, set }) => + async () => { + const recordField = visibleRecordFields.find( + findByProperty('fieldMetadataItemId', resizedFieldMetadataItemId), + ); + + throwIfNotDefined(recordField, 'recordField'); + + if (!resizedFieldMetadataItemId) return; + + const resizeFieldOffset = getSnapshotValue( + snapshot, + resizeFieldOffsetCallbackState, + ); + + const nextWidth = Math.round( + Math.max( + recordField.size + resizeFieldOffset, + COLUMN_RESIZE_MIN_WIDTH, + ), + ); + + set(resizeFieldOffsetCallbackState, 0); + setInitialPointerPositionX(null); + setResizedFieldMetadataItemId(null); + + if (nextWidth !== recordField.size) { + const updatedRecordField = updateRecordField( + resizedFieldMetadataItemId, + { + size: nextWidth, + }, + ); + + saveRecordFields([updatedRecordField]); + } + }, + [ + saveRecordFields, + resizedFieldMetadataItemId, + resizeFieldOffsetCallbackState, + setResizedFieldMetadataItemId, + updateRecordField, + visibleRecordFields, + ], + ); + + useTrackPointer({ + shouldTrackPointer: resizedFieldMetadataItemId !== null, + onMouseDown: handleResizeHandlerStart, + onMouseMove: handleResizeHandlerMove, + onMouseUp: handleResizeHandlerEnd, + }); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableCellsVisible.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableCellsVisible.tsx index baf22cc37f..3bbb60b797 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableCellsVisible.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableCellsVisible.tsx @@ -2,12 +2,13 @@ import { useRecordTableContextOrThrow } from '@/object-record/record-table/conte import { useRecordTableRowContextOrThrow } from '@/object-record/record-table/contexts/RecordTableRowContext'; import { useRecordTableRowDraggableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableRowDraggableContext'; import { RecordTableCell } from '@/object-record/record-table/record-table-cell/components/RecordTableCell'; +import { RecordTableCellFirstRowFirstColumn } from '@/object-record/record-table/record-table-cell/components/RecordTableCellFirstRowFirstColumn'; import { RecordTableCellWrapper } from '@/object-record/record-table/record-table-cell/components/RecordTableCellWrapper'; import { RecordTableTd } from '@/object-record/record-table/record-table-cell/components/RecordTableTd'; import { isNonEmptyArray } from '~/utils/isNonEmptyArray'; export const RecordTableCellsVisible = () => { - const { isSelected } = useRecordTableRowContextOrThrow(); + const { isSelected, rowIndex } = useRecordTableRowContextOrThrow(); const { isDragging } = useRecordTableRowDraggableContextOrThrow(); @@ -19,19 +20,31 @@ export const RecordTableCellsVisible = () => { const recordFieldsAfterFirst = visibleRecordFields.slice(1); + const isFirstRow = rowIndex === 0; + return ( <> - - - + {isFirstRow ? ( + + + + ) : ( + + + + )} {recordFieldsAfterFirst.map((recordField, recordFieldIndex) => ( theme.border.color.medium}; border-top: 1px solid ${({ theme }) => theme.border.color.medium}; @@ -49,7 +51,8 @@ const StyledTr = styled.div<{ } &[data-active='true'] { - div.table-cell { + div.table-cell, + div.table-cell-0-0 { &:not(:first-of-type) { border-bottom: 1px solid ${({ theme }) => theme.adaptiveColors.blue3}; border-top: 1px solid ${({ theme }) => theme.adaptiveColors.blue3}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/isRecordTableScrolledHorizontallyComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/states/isRecordTableScrolledHorizontallyComponentState.ts index 663b866979..105faa4151 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/states/isRecordTableScrolledHorizontallyComponentState.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/states/isRecordTableScrolledHorizontallyComponentState.ts @@ -5,5 +5,5 @@ export const isRecordTableScrolledHorizontallyComponentState = createComponentState({ key: 'isRecordTableScrolledHorizontallyComponentState', componentInstanceContext: RecordTableComponentInstanceContext, - defaultValue: true, + defaultValue: false, }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/isRecordTableScrolledVerticallyComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/states/isRecordTableScrolledVerticallyComponentState.ts index 0e4e3ef5ff..683116ac4c 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/states/isRecordTableScrolledVerticallyComponentState.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/states/isRecordTableScrolledVerticallyComponentState.ts @@ -5,5 +5,5 @@ export const isRecordTableScrolledVerticallyComponentState = createComponentState({ key: 'isRecordTableScrolledVerticallyComponentState', componentInstanceContext: RecordTableComponentInstanceContext, - defaultValue: true, + defaultValue: false, }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/resizedFieldMetadataIdComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/states/resizedFieldMetadataIdComponentState.ts new file mode 100644 index 0000000000..7ace7819d5 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/states/resizedFieldMetadataIdComponentState.ts @@ -0,0 +1,11 @@ +import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; +import { type Nullable } from 'twenty-shared/types'; + +export const resizedFieldMetadataIdComponentState = createComponentState< + Nullable +>({ + key: 'resizedFieldMetadataIdComponentState', + defaultValue: null, + componentInstanceContext: RecordTableComponentInstanceContext, +}); diff --git a/packages/twenty-shared/src/types/Nullable.ts b/packages/twenty-shared/src/types/Nullable.ts new file mode 100644 index 0000000000..7b5ecba936 --- /dev/null +++ b/packages/twenty-shared/src/types/Nullable.ts @@ -0,0 +1 @@ +export type Nullable = T | null | undefined; \ No newline at end of file diff --git a/packages/twenty-shared/src/types/index.ts b/packages/twenty-shared/src/types/index.ts index d298fa231a..743ec5fefd 100644 --- a/packages/twenty-shared/src/types/index.ts +++ b/packages/twenty-shared/src/types/index.ts @@ -18,6 +18,7 @@ export { FieldMetadataType } from './FieldMetadataType'; export type { FromTo } from './FromToType'; export type { IsExactly } from './IsExactly'; export type { NonNullableRequired } from './NonNullableRequired'; +export type { Nullable } from './Nullable'; export type { NullablePartial } from './NullablePartial'; export type { ObjectPermissions } from './ObjectPermissions'; export type { ObjectPermissionsDeprecated } from './ObjectPermissionsDeprecated';