Refactored table body and footer with divs (#14346)

This PR refactors what is remaining of HTML table API to divs.

It is mainly about the body and aggregate footer.

Because a `position: sticky` creates a stacking context, and because a
div wrapping other divs prevents those children divs from being sticky,
it has been found that removing the wrapping container of both header
and footer allows us to have all z-index in the same stacking context
and create the right experience.

Though the fine-tuning of z-index will be done in another PR.

There are many fixes left that will be addressed very soon in subsequent
PRs.

This PR focuses on bringing a functional table both with and without
RecordGroups. (Check Task views for that)
This commit is contained in:
Lucas Bordeau
2025-09-08 12:13:50 +02:00
committed by GitHub
parent 9fe88c2639
commit 8fa1821e1a
27 changed files with 413 additions and 386 deletions
@@ -1,23 +1,105 @@
import { RecordTableStickyBottomEffect } from '@/object-record/record-table/components/RecordTableStickyBottomEffect';
import { RecordTableStickyEffect } from '@/object-record/record-table/components/RecordTableStickyEffect';
import { StyledTableDiv } from '@/object-record/record-table/components/RecordTableStyles';
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(StyledTableDiv)<{
const StyledTableWithPointerEvents = styled.div<{
isDragging: boolean;
stickyColumnZIndex: number;
normalColumnZIndex: number;
}>`
& > * {
pointer-events: ${({ isDragging }) => (isDragging ? 'none' : 'auto')};
}
display: flex;
flex-wrap: wrap;
div.header-cell {
position: sticky;
top: 0;
}
div.header-cell:nth-of-type(n + 3) {
z-index: ${({ normalColumnZIndex }) => normalColumnZIndex};
}
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};
}
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};
}
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};
// &::after {
// content: '';
// position: absolute;
// top: -1px;
// height: calc(100% + 2px);
// width: 4px;
// right: 0px;
// box-shadow: ${({ theme }) => theme.boxShadow.light};
// clip-path: inset(0px -4px 0px 0px);
// }
@media (max-width: ${MOBILE_VIEWPORT}px) {
width: 38px;
max-width: 38px;
min-width: 38px;
}
}
div.footer-cell:nth-of-type(n + 3) {
z-index: ${TABLE_Z_INDEX.footer.default};
position: sticky;
bottom: 0;
}
div.footer-cell:nth-of-type(1) {
z-index: ${TABLE_Z_INDEX.footer.stickyColumn};
left: 0px;
bottom: 0;
position: sticky;
}
div.footer-cell:nth-of-type(2) {
z-index: ${TABLE_Z_INDEX.footer.stickyColumn};
left: 48px;
bottom: 0;
position: sticky;
}
`;
const StyledTableContainer = styled.div`
@@ -66,9 +148,40 @@ 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;
return (
<StyledTableContainer ref={containerRef}>
<StyledTableWithPointerEvents ref={tableBodyRef} isDragging={isDragging}>
<StyledTableWithPointerEvents
ref={tableBodyRef}
isDragging={isDragging}
stickyColumnZIndex={computedStickyColumnZIndex}
normalColumnZIndex={computedNormalColumnZIndex}
>
<RecordTableHeader />
{hasRecordGroups ? (
<RecordTableRecordGroupsBody />
@@ -2,9 +2,7 @@ import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record
import { RecordTableAddNew } from '@/object-record/record-table/components/RecordTableAddNew';
import { RecordTableBodyDroppablePlaceholder } from '@/object-record/record-table/record-table-body/components/RecordTableBodyDroppablePlaceholder';
import { RecordTableBodyFetchMoreLoader } from '@/object-record/record-table/record-table-body/components/RecordTableBodyFetchMoreLoader';
import { RecordTableAggregateFooter } from '@/object-record/record-table/record-table-footer/components/RecordTableAggregateFooter';
import { RecordTableRow } from '@/object-record/record-table/record-table-row/components/RecordTableRow';
import { isRecordTableInitialLoadingComponentState } from '@/object-record/record-table/states/isRecordTableInitialLoadingComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
export const RecordTableNoRecordGroupRows = () => {
@@ -12,10 +10,6 @@ export const RecordTableNoRecordGroupRows = () => {
recordIndexAllRecordIdsComponentSelector,
);
const isRecordTableInitialLoading = useRecoilComponentValue(
isRecordTableInitialLoadingComponentState,
);
return (
<>
{allRecordIds.map((recordId, rowIndex) => {
@@ -31,9 +25,6 @@ export const RecordTableNoRecordGroupRows = () => {
<RecordTableBodyFetchMoreLoader />
<RecordTableBodyDroppablePlaceholder />
<RecordTableAddNew />
{!isRecordTableInitialLoading && allRecordIds.length > 0 && (
<RecordTableAggregateFooter />
)}
</>
);
};
@@ -1,30 +1,3 @@
import { useEffect } from 'react';
import { scrollWrapperScrollBottomComponentState } from '@/ui/utilities/scroll/states/scrollWrapperScrollBottomComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
export const RecordTableStickyBottomEffect = () => {
const scrollBottom = useRecoilComponentValue(
scrollWrapperScrollBottomComponentState,
);
useEffect(() => {
if (scrollBottom > 1) {
document
.getElementById('record-table-body')
?.classList.add('footer-sticky');
document
.getElementById('record-table-footer')
?.classList.add('footer-sticky');
} else {
document
.getElementById('record-table-body')
?.classList.remove('footer-sticky');
document
.getElementById('record-table-footer')
?.classList.remove('footer-sticky');
}
}, [scrollBottom]);
return <></>;
};
@@ -18,16 +18,6 @@ export const RecordTableStickyEffect = () => {
useEffect(() => {
setIsRecordTableScrolledVertically(scrollTop > 0);
if (scrollTop > 0) {
document
.getElementById('record-table-header')
?.classList.add('header-sticky');
} else {
document
.getElementById('record-table-header')
?.classList.remove('header-sticky');
}
}, [scrollTop, setIsRecordTableScrolledVertically]);
const scrollLeft = useRecoilComponentValue(
@@ -40,28 +30,6 @@ export const RecordTableStickyEffect = () => {
useEffect(() => {
setIsRecordTableScrolledHorizontally(scrollLeft > 0);
if (scrollLeft > 0) {
document
.getElementById('record-table-body')
?.classList.add('first-columns-sticky');
document
.getElementById('record-table-header')
?.classList.add('first-columns-sticky');
document
.getElementById('record-table-footer')
?.classList.add('first-columns-sticky');
} else {
document
.getElementById('record-table-body')
?.classList.remove('first-columns-sticky');
document
.getElementById('record-table-header')
?.classList.remove('first-columns-sticky');
document
.getElementById('record-table-footer')
?.classList.remove('first-columns-sticky');
}
}, [scrollLeft, setIsRecordTableScrolledHorizontally]);
return <></>;
@@ -1,11 +1,3 @@
import styled from '@emotion/styled';
export const StyledTableDiv = styled.div`
border-radius: ${({ theme }) => theme.border.radius.sm};
border-spacing: 0;
width: 100%;
.footer-sticky tr:nth-last-of-type(2) td {
border-bottom-color: ${({ theme }) => theme.background.transparent};
}
`;
export const StyledTableDiv = styled.div``;
@@ -6,7 +6,7 @@ export const TABLE_Z_INDEX = {
editMode: 20,
},
footer: {
default: 12,
default: 18,
stickyColumn: 20,
},
noScrollAtAll: {
@@ -3,32 +3,33 @@ 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.tbody`
const StyledTbody = styled.div`
// TODO: re-implement horizontal scroll here after table have been refactored to divs
td:nth-of-type(1) {
div.table-cell:nth-of-type(1) {
position: sticky;
left: 0px;
z-index: ${TABLE_Z_INDEX.cell.sticky};
}
td:nth-of-type(2) {
div.table-cell:nth-of-type(2) {
position: sticky;
left: 16px;
z-index: ${TABLE_Z_INDEX.cell.sticky};
}
tr:not(:last-child) td:nth-of-type(3) {
div.table-cell:nth-of-type(3) {
position: sticky;
left: 49px;
left: 48px;
z-index: ${TABLE_Z_INDEX.cell.sticky};
}
td:nth-of-type(3) {
@media (max-width: ${MOBILE_VIEWPORT}px) {
width: ${38}px;
max-width: ${38}px;
}
}
display: flex;
flex-wrap: wrap;
`;
export const RecordTableBody = StyledTbody;
@@ -2,19 +2,10 @@ import { RecordTableBody } from '@/object-record/record-table/record-table-body/
import { RecordTableBodyDroppableContextProvider } from '@/object-record/record-table/record-table-body/contexts/RecordTableBodyDroppableContext';
import { recordTableHoverPositionComponentState } from '@/object-record/record-table/states/recordTableHoverPositionComponentState';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import styled from '@emotion/styled';
import { Droppable } from '@hello-pangea/dnd';
import { type ReactNode, useState } from 'react';
import { v4 } from 'uuid';
const StyledTable = styled.table`
table-layout: fixed;
border-radius: ${({ theme }) => theme.border.radius.sm};
border-spacing: 0;
width: 100%;
`;
type RecordTableBodyDroppableProps = {
children: ReactNode;
recordGroupId?: string;
@@ -39,21 +30,19 @@ export const RecordTableBodyDroppable = ({
isDropDisabled={isDropDisabled}
>
{(provided) => (
<StyledTable>
<RecordTableBody
id={recordTableBodyId}
ref={provided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
onMouseLeave={() => setRecordTableHoverPosition(null)}
<RecordTableBody
id={recordTableBodyId}
ref={provided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
onMouseLeave={() => setRecordTableHoverPosition(null)}
>
<RecordTableBodyDroppableContextProvider
value={{ droppablePlaceholder: provided.placeholder }}
>
<RecordTableBodyDroppableContextProvider
value={{ droppablePlaceholder: provided.placeholder }}
>
{children}
</RecordTableBodyDroppableContextProvider>
</RecordTableBody>
</StyledTable>
{children}
</RecordTableBodyDroppableContextProvider>
</RecordTableBody>
)}
</Droppable>
);
@@ -56,13 +56,14 @@ export const RecordTableBodyFetchMoreLoader = () => {
if (!showLoadingMoreRow) {
return <></>;
}
// TODO: fix here styling
return (
<tr ref={tbodyRef}>
<td colSpan={7}>
<div ref={tbodyRef}>
<div>
<StyledText>Loading more...</StyledText>
</td>
<td colSpan={7} />
</tr>
</div>
<div />
</div>
);
};
@@ -5,6 +5,7 @@ import { RecordTableBodyDragDropContextProvider } from '@/object-record/record-t
import { RecordTableBodyDroppable } from '@/object-record/record-table/record-table-body/components/RecordTableBodyDroppable';
import { RecordTableBodyLoading } from '@/object-record/record-table/record-table-body/components/RecordTableBodyLoading';
import { RecordTableCellPortals } from '@/object-record/record-table/record-table-cell/components/RecordTableCellPortals';
import { RecordTableAggregateFooter } from '@/object-record/record-table/record-table-footer/components/RecordTableAggregateFooter';
import { isRecordTableInitialLoadingComponentState } from '@/object-record/record-table/states/isRecordTableInitialLoadingComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
@@ -28,6 +29,9 @@ export const RecordTableNoRecordGroupBody = () => {
<RecordTableNoRecordGroupRows />
<RecordTableCellPortals />
</RecordTableBodyDroppable>
{!isRecordTableInitialLoading && allRecordIds.length > 0 && (
<RecordTableAggregateFooter />
)}
</RecordTableBodyDragDropContextProvider>
</RecordTableNoRecordGroupBodyContextProvider>
);
@@ -5,7 +5,7 @@ import { ThemeContext } from 'twenty-ui/theme';
export const DEFAULT_RECORD_TABLE_TD_WIDTH = 32;
const StyledTd = styled.td<{
const StyledTd = styled.div<{
backgroundColor: string;
borderColor: string;
isDragging?: boolean;
@@ -74,6 +74,7 @@ export const RecordTableTd = ({
width={width}
// eslint-disable-next-line react/jsx-props-no-spreading
{...dragHandleProps}
className="table-cell"
>
{children}
</StyledTd>
@@ -4,81 +4,14 @@ import { TABLE_Z_INDEX } from '@/object-record/record-table/constants/TableZInde
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableAggregateFooterCell } from '@/object-record/record-table/record-table-footer/components/RecordTableAggregateFooterCell';
import { RecordTableColumnAggregateFooterCellContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterCellContext';
import { FIRST_TH_WIDTH } from '@/object-record/record-table/record-table-header/components/RecordTableHeader';
import { useScrollWrapperElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperElement';
import { isUndefined } from '@sniptt/guards';
import { MOBILE_VIEWPORT } from 'twenty-ui/theme';
const StyledTd = styled.td`
const StyledPlaceholderFirstCell = styled.div`
background-color: ${({ theme }) => theme.background.primary};
`;
const StyledTableRow = styled.tr<{
hasHorizontalOverflow?: boolean;
}>`
z-index: ${TABLE_Z_INDEX.footer.default};
width: 48px;
position: sticky;
border: none;
// TODO: see how we reimplement horizontal scrolling after all table has been refactored
td {
border-top: ${({ theme }) => `1px solid ${theme.border.color.light}`};
z-index: ${TABLE_Z_INDEX.footer.default};
position: sticky;
bottom: 0;
}
cursor: pointer;
td:nth-of-type(1) {
width: ${FIRST_TH_WIDTH};
left: 0;
border-top: none;
}
td:nth-of-type(1) {
position: sticky;
z-index: ${TABLE_Z_INDEX.footer.stickyColumn};
}
td:nth-of-type(2) {
position: sticky;
z-index: ${TABLE_Z_INDEX.footer.stickyColumn};
transition: 0.3s ease;
&::after {
content: '';
position: absolute;
top: -1px;
height: calc(100% + 2px);
width: 4px;
right: 0px;
box-shadow: ${({ theme }) => theme.boxShadow.light};
clip-path: inset(0px -4px 0px 0px);
}
@media (max-width: ${MOBILE_VIEWPORT}px) {
width: 38px;
max-width: 38px;
}
}
background: ${({ theme }) => theme.background.primary};
${({ hasHorizontalOverflow }) =>
`.footer-sticky {
bottom: ${hasHorizontalOverflow ? '10px' : '0'};
${
hasHorizontalOverflow &&
`
&::after {
content: '';
position: absolute;
bottom: -10px;
left: 0;
right: 0;
height: 10px;
background: inherit;
}
}
`
}
`}
left: 0px;
bottom: 0;
z-index: ${TABLE_Z_INDEX.footer.stickyColumn};
`;
export const RecordTableAggregateFooter = ({
@@ -88,21 +21,9 @@ export const RecordTableAggregateFooter = ({
}) => {
const { visibleRecordFields } = useRecordTableContextOrThrow();
const { scrollWrapperHTMLElement } = useScrollWrapperElement();
const hasHorizontalOverflow =
(scrollWrapperHTMLElement?.scrollWidth ?? 0) >
(scrollWrapperHTMLElement?.clientWidth ?? 0);
return (
<StyledTableRow
id={`record-table-footer${currentRecordGroupId ? '-' + currentRecordGroupId : ''}`}
data-select-disable
hasHorizontalOverflow={
hasHorizontalOverflow && isUndefined(currentRecordGroupId)
}
>
<StyledTd />
<>
<StyledPlaceholderFirstCell />
{visibleRecordFields.map((recordField, index) => {
return (
<RecordTableColumnAggregateFooterCellContext.Provider
@@ -119,9 +40,10 @@ export const RecordTableAggregateFooter = ({
</RecordTableColumnAggregateFooterCellContext.Provider>
);
})}
<td colSpan={visibleRecordFields.length - 1} />
<td />
<td />
</StyledTableRow>
{/* TODO: fix span for divs styling here colSpan={visibleRecordFields.length - 1}*/}
<div />
<div />
<div />
</>
);
};
@@ -1,23 +1,21 @@
import styled from '@emotion/styled';
import { useContext } from 'react';
import { TABLE_Z_INDEX } from '@/object-record/record-table/constants/TableZIndex';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableColumnAggregateFooterCellContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterCellContext';
import { RecordTableColumnFooterWithDropdown } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterWithDropdown';
import { findByProperty, isDefined } from 'twenty-shared/utils';
const COLUMN_MIN_WIDTH = 104;
const StyledColumnFooterCell = styled.td<{
const StyledColumnFooterCell = styled.div<{
columnWidth: number;
isFirstCell?: boolean;
}>`
background-color: ${({ theme }) => theme.background.primary};
color: ${({ theme }) => theme.font.color.tertiary};
overflow: hidden;
padding: 0;
position: relative;
${({ columnWidth }) => `
min-width: ${columnWidth}px;
width: ${columnWidth}px;
@@ -36,6 +34,11 @@ const StyledColumnFooterCell = styled.td<{
}};
height: 32px;
position: sticky;
left: 48px;
bottom: 0;
z-index: ${TABLE_Z_INDEX.footer.stickyColumn};
user-select: none;
overflow: auto;
scrollbar-width: none;
@@ -74,9 +77,11 @@ export const RecordTableAggregateFooterCell = ({
return (
<StyledColumnFooterCell
columnWidth={Math.max(recordField.size + 24, COLUMN_MIN_WIDTH)}
colSpan={isFirstCell ? 2 : undefined}
columnWidth={recordField.size + 1}
// TODO: fix colspan
// colSpan={isFirstCell ? 2 : undefined}
isFirstCell={isFirstCell}
className={isFirstCell ? '' : 'footer-cell'}
>
<StyledColumnFootContainer>
<RecordTableColumnFooterWithDropdown
@@ -1,131 +1,17 @@
import styled from '@emotion/styled';
import { TABLE_Z_INDEX } from '@/object-record/record-table/constants/TableZIndex';
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 { RecordTableHeaderLastColumn } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderLastColumn';
import { isRecordTableScrolledHorizontallyComponentState } from '@/object-record/record-table/states/isRecordTableScrolledHorizontallyComponentState';
import { isRecordTableScrolledVerticallyComponentState } from '@/object-record/record-table/states/isRecordTableScrolledVerticallyComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { MOBILE_VIEWPORT } from 'twenty-ui/theme';
export const FIRST_TH_WIDTH = '10px';
const StyledTableHead = styled.div<{
stickyColumnZIndex: number;
normalColumnZIndex: number;
entireRowZIndex: number;
}>`
cursor: pointer;
display: flex;
flex-direction: row;
align-items: center;
height: 32px;
background-color: ${({ theme }) => theme.background.primary};
div.header-cell:nth-of-type(n + 3) {
z-index: ${({ normalColumnZIndex }) => normalColumnZIndex};
}
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};
}
div.header-cell:nth-of-type(2) {
position: sticky;
left: 17px;
top: 0;
z-index: ${({ stickyColumnZIndex }) => stickyColumnZIndex};
transition: 0.3s ease;
background-color: ${({ theme }) => theme.background.primary};
}
div.header-cell:nth-of-type(3) {
position: sticky;
left: 49px;
right: 0;
z-index: ${({ stickyColumnZIndex }) => stickyColumnZIndex};
transition: 0.3s ease;
background-color: ${({ theme }) => theme.background.primary};
// &::after {
// content: '';
// position: absolute;
// top: -1px;
// height: calc(100% + 2px);
// width: 4px;
// right: 0px;
// box-shadow: ${({ theme }) => theme.boxShadow.light};
// clip-path: inset(0px -4px 0px 0px);
// }
@media (max-width: ${MOBILE_VIEWPORT}px) {
width: 38px;
max-width: 38px;
min-width: 38px;
}
}
position: sticky;
top: 0px;
z-index: ${({ entireRowZIndex }) => entireRowZIndex};
`;
export const RecordTableHeader = () => {
const { visibleRecordFields } = useRecordTableContextOrThrow();
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 computedHeaderRowZIndex =
isRecordTableScrolledHorizontally && isRecordTableScrolledVertically
? TABLE_Z_INDEX.scrolledBothVerticallyAndHorizontally.headerRow
: isRecordTableScrolledHorizontally
? TABLE_Z_INDEX.scrolledHorizontallyOnly.headerRow
: isRecordTableScrolledVertically
? TABLE_Z_INDEX.scrolledVerticallyOnly.headerRow
: TABLE_Z_INDEX.noScrollAtAll.headerRow;
return (
<StyledTableHead
id="record-table-header"
data-select-disable
stickyColumnZIndex={computedStickyColumnZIndex}
normalColumnZIndex={computedNormalColumnZIndex}
entireRowZIndex={computedHeaderRowZIndex}
>
<>
<RecordTableHeaderDragDropColumn />
<RecordTableHeaderCheckboxColumn />
{visibleRecordFields.map((recordField) => (
@@ -136,6 +22,6 @@ export const RecordTableHeader = () => {
))}
<RecordTableHeaderAddColumnButton />
<RecordTableHeaderLastColumn />
</StyledTableHead>
</>
);
};
@@ -29,6 +29,9 @@ const StyledPlusIconHeaderCell = styled.div<{
z-index: 1;
height: 32px;
max-height: 32px;
&:hover {
background: ${({ theme }) => theme.background.transparent.secondary};
}
@@ -72,6 +75,7 @@ export const RecordTableHeaderAddColumnButton = () => {
<StyledPlusIconHeaderCell
isTableWiderThanScreen={isTableWiderThanScreen}
isFirstRowActiveOrFocused={isFirstRowActiveOrFocused}
className="header-cell"
>
<StyledDropdownContainer>
<Dropdown
@@ -25,7 +25,7 @@ import { throwIfNotDefined } from 'twenty-shared/utils';
import { IconPlus } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
const COLUMN_MIN_WIDTH = 104;
const COLUMN_MIN_WIDTH = 48;
const StyledColumnHeaderCell = styled.div<{
columnWidth: number;
@@ -36,6 +36,9 @@ const StyledColumnHeaderCell = styled.div<{
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};
@@ -20,6 +20,7 @@ const StyledContainer = styled.div`
min-width: 24px;
padding-right: ${({ theme }) => theme.spacing(1)};
background-color: ${({ theme }) => theme.background.primary};
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
`;
const StyledColumnHeaderCell = styled.div<{
@@ -31,6 +32,7 @@ const StyledColumnHeaderCell = styled.div<{
box-sizing: border-box;
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
max-height: 32px;
`;
export const RecordTableHeaderCheckboxColumn = () => {
@@ -2,10 +2,13 @@ import { styled } from '@linaria/react';
import { useContext } from 'react';
import { ThemeContext } from 'twenty-ui/theme';
const StyledTh = styled.div<{ backgroundColor: string }>`
const StyledDragDropHeaderCell = styled.div<{ backgroundColor: string }>`
background-color: ${({ backgroundColor }) => backgroundColor};
min-width: 17px;
min-height: 100%;
min-width: 16px;
width: 16px;
max-width: 16px;
min-height: 32px;
max-height: 32px;
border-bottom: 1px solid ${({ backgroundColor }) => backgroundColor};
`;
@@ -14,9 +17,9 @@ export const RecordTableHeaderDragDropColumn = () => {
const { theme } = useContext(ThemeContext);
return (
<StyledTh
<StyledDragDropHeaderCell
className="header-cell"
backgroundColor={theme.background.primary}
></StyledTh>
/>
);
};
@@ -7,10 +7,13 @@ const StyledLastColumnHeader = styled.div`
border-left: none !important;
color: ${({ theme }) => theme.font.color.tertiary};
width: 100%;
width: fit-content;
height: 32px;
max-height: 32px;
`;
export const RecordTableHeaderLastColumn = () => {
return <StyledLastColumnHeader></StyledLastColumnHeader>;
return (
<StyledLastColumnHeader className="header-cell"></StyledLastColumnHeader>
);
};
@@ -1,27 +1,60 @@
import styled from '@emotion/styled';
import { TABLE_Z_INDEX } from '@/object-record/record-table/constants/TableZIndex';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableTd } from '@/object-record/record-table/record-table-cell/components/RecordTableTd';
import { useTheme } from '@emotion/react';
import {
filterOutByProperty,
findByProperty,
sumByProperty,
} from 'twenty-shared/utils';
import { type IconComponent } from 'twenty-ui/display';
const StyledRecordTableDraggableTr = styled.tr`
const StyledDragDropPlaceholderCell = styled.div`
min-width: 16px;
width: 16px;
position: sticky;
left: 0;
`;
const StyledPlusButtonPlaceholderCell = styled.div`
height: 32px;
min-width: 32px;
width: 32px;
&:hover {
background-color: ${({ theme }) => theme.background.transparent.light};
}
`;
const StyledFieldPlaceholderCell = styled.div<{ widthOfFields: number }>`
height: 32px;
min-width: ${({ widthOfFields }) => widthOfFields}px;
width: ${({ widthOfFields }) => widthOfFields}px;
&:hover {
background-color: ${({ theme }) => theme.background.transparent.light};
}
`;
const StyledRecordTableDraggableTr = styled.div`
cursor: pointer;
transition: background-color ${({ theme }) => theme.animation.duration.fast}
ease-in-out;
border: none;
background: ${({ theme }) => theme.background.primary};
position: relative;
z-index: ${TABLE_Z_INDEX.base};
display: flex;
flex-direction: row;
align-items: center;
&:hover {
td:not(:first-of-type) {
div:not(:first-of-type) {
background-color: ${({ theme }) => theme.background.transparent.light};
}
}
td {
div {
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
background-color: ${({ theme }) => theme.background.primary};
transition: background-color ${({ theme }) => theme.animation.duration.fast}
@@ -31,9 +64,11 @@ const StyledRecordTableDraggableTr = styled.tr`
border-bottom: 1px solid ${({ theme }) => theme.background.primary};
}
}
width: 100%;
`;
const StyledIconContainer = styled(RecordTableTd)`
const StyledIconContainer = styled.div`
align-items: center;
background-color: transparent;
border-right: none;
@@ -41,12 +76,24 @@ const StyledIconContainer = styled(RecordTableTd)`
display: flex;
height: 32px;
justify-content: center;
width: 32px;
position: sticky;
left: 16px;
`;
const StyledRecordTableTdTextContainer = styled(RecordTableTd)`
const StyledRecordTableTdTextContainer = styled.div<{ width: number }>`
align-items: center;
background-color: transparent;
border-right: none;
display: flex;
height: 32px;
justify-content: start;
left: 48px;
position: sticky;
width: ${({ width }) => width}px;
`;
const StyledText = styled.span`
@@ -60,7 +107,7 @@ const StyledText = styled.span`
type RecordTableActionRowProps = {
LeftIcon: IconComponent;
text: string;
onClick?: (event?: React.MouseEvent<HTMLTableRowElement>) => void;
onClick?: (event?: React.MouseEvent<HTMLDivElement>) => void;
};
export const RecordTableActionRow = ({
@@ -71,10 +118,28 @@ export const RecordTableActionRow = ({
const theme = useTheme();
const { visibleRecordFields } = useRecordTableContextOrThrow();
const { labelIdentifierFieldMetadataItem } = useRecordIndexContextOrThrow();
const visibleRecordFieldsWithoutLabelIdentifier = visibleRecordFields.filter(
filterOutByProperty(
'fieldMetadataItemId',
labelIdentifierFieldMetadataItem?.id,
),
);
const labelIdentifierRecordField = visibleRecordFields.find(
findByProperty('fieldMetadataItemId', labelIdentifierFieldMetadataItem?.id),
);
const sumOfWidthOfVisibleRecordFieldsAfterLabelIdentifierField =
visibleRecordFieldsWithoutLabelIdentifier.reduce(sumByProperty('size'), 0);
const sumOfBorderWidthForFields =
visibleRecordFieldsWithoutLabelIdentifier.length;
return (
<StyledRecordTableDraggableTr onClick={onClick}>
<td aria-hidden />
<StyledDragDropPlaceholderCell />
<StyledIconContainer>
<LeftIcon
stroke={theme.icon.stroke.sm}
@@ -82,12 +147,18 @@ export const RecordTableActionRow = ({
color={theme.font.color.tertiary}
/>
</StyledIconContainer>
<StyledRecordTableTdTextContainer className="disable-shadow">
<StyledRecordTableTdTextContainer
width={labelIdentifierRecordField?.size ?? 104}
>
<StyledText>{text}</StyledText>
</StyledRecordTableTdTextContainer>
<td colSpan={visibleRecordFields.length - 1} aria-hidden />
<td aria-hidden />
<td aria-hidden />
<StyledFieldPlaceholderCell
widthOfFields={
sumOfWidthOfVisibleRecordFieldsAfterLabelIdentifierField +
sumOfBorderWidthForFields
}
/>
<StyledPlusButtonPlaceholderCell />
</StyledRecordTableDraggableTr>
);
};
@@ -2,9 +2,9 @@ import { useTheme } from '@emotion/react';
import { Draggable } from '@hello-pangea/dnd';
import { type ReactNode } from 'react';
import { useRecordDragState } from '@/object-record/record-drag/shared/hooks/useRecordDragState';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableRowDraggableContextProvider } from '@/object-record/record-table/contexts/RecordTableRowDraggableContext';
import { useRecordDragState } from '@/object-record/record-drag/shared/hooks/useRecordDragState';
import { RecordTableRowMultiDragPreview } from '@/object-record/record-table/record-table-row/components/RecordTableRowMultiDragPreview';
import { RecordTableTr } from '@/object-record/record-table/record-table-row/components/RecordTableTr';
import { RecordTableTrEffect } from '@/object-record/record-table/record-table-row/components/RecordTableTrEffect';
@@ -61,7 +61,7 @@ export const RecordTableDraggableTr = ({
borderColor: draggableSnapshot.isDragging
? `${theme.border.color.medium}`
: 'transparent',
opacity: isSecondaryDragged ? 0.3 : 1,
opacity: isSecondaryDragged ? 0.3 : undefined,
}}
isDragging={draggableSnapshot.isDragging}
data-testid={`row-id-${recordId}`}
@@ -12,25 +12,23 @@ import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/ho
import styled from '@emotion/styled';
import { forwardRef, type ReactNode } from 'react';
const StyledTr = styled.tr<{
const StyledTr = styled.div<{
isDragging: boolean;
}>`
border: ${({ isDragging, theme }) =>
isDragging
? `1px solid ${theme.border.color.medium}`
: '1px solid transparent'};
border-top: ${({ isDragging, theme }) =>
isDragging ? `1px solid ${theme.border.color.medium}` : 'none'};
border-left: none;
position: relative;
display: flex;
flex-direction: row;
&[data-next-row-active-or-focused='true'] {
td {
div.table-cell {
border-bottom: none;
}
}
&[data-focused='true'] {
td {
div.table-cell {
&:not(:first-of-type) {
border-bottom: 1px solid ${({ theme }) => theme.border.color.medium};
border-top: 1px solid ${({ theme }) => theme.border.color.medium};
@@ -51,7 +49,7 @@ const StyledTr = styled.tr<{
}
&[data-active='true'] {
td {
div.table-cell {
&:not(:first-of-type) {
border-bottom: 1px solid ${({ theme }) => theme.adaptiveColors.blue3};
border-top: 1px solid ${({ theme }) => theme.adaptiveColors.blue3};
@@ -145,6 +143,7 @@ export const RecordTableTr = forwardRef<
}}
>
<StyledTr
className="table-row"
data-virtualized-id={recordId}
isDragging={isDragging}
ref={ref}
@@ -3,30 +3,53 @@ import styled from '@emotion/styled';
import { useCallback } from 'react';
import { RecordBoardColumnHeaderAggregateDropdown } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdown';
import { visibleRecordFieldsComponentSelector } from '@/object-record/record-field/states/visibleRecordFieldsComponentSelector';
import { useCurrentRecordGroupId } from '@/object-record/record-group/hooks/useCurrentRecordGroupId';
import { recordGroupDefinitionFamilyState } from '@/object-record/record-group/states/recordGroupDefinitionFamilyState';
import { RecordGroupDefinitionType } from '@/object-record/record-group/types/RecordGroupDefinition';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableTd } from '@/object-record/record-table/record-table-cell/components/RecordTableTd';
import { RecordTableRecordGroupStickyEffect } from '@/object-record/record-table/record-table-section/components/RecordTableRecordGroupStickyEffect';
import { useAggregateRecordsForRecordTableSection } from '@/object-record/record-table/record-table-section/hooks/useAggregateRecordsForRecordTableSection';
import { isRecordGroupTableSectionToggledComponentState } from '@/object-record/record-table/record-table-section/states/isRecordGroupTableSectionToggledComponentState';
import { useRecoilComponentFamilyState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import {
filterOutByProperty,
findByProperty,
isDefined,
sumByProperty,
} from 'twenty-shared/utils';
import { Tag } from 'twenty-ui/components';
import { IconChevronDown } from 'twenty-ui/display';
import { AnimatedLightIconButton } from 'twenty-ui/input';
const StyledTrContainer = styled.tr`
cursor: pointer;
const StyledDragDropHeaderPlaceholder = styled.div`
min-width: 16px;
width: 16px;
position: sticky;
left: 0;
`;
const StyledChevronContainer = styled(RecordTableTd)`
const StyledTrContainer = styled.div`
cursor: pointer;
display: flex;
flex-direction: row;
`;
const StyledChevronContainer = styled.div`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
border-right: none;
color: ${({ theme }) => theme.font.color.secondary};
display: flex;
text-align: center;
vertical-align: middle;
width: 32px;
min-width: 32px;
position: sticky;
left: 16px;
`;
const StyledAnimatedLightIconButton = styled(AnimatedLightIconButton)`
@@ -34,33 +57,63 @@ const StyledAnimatedLightIconButton = styled(AnimatedLightIconButton)`
margin: auto;
`;
const StyledRecordGroupSection = styled(RecordTableTd)`
border-right: none;
height: 32px;
display: flex;
const StyledRecordGroupSection = styled.div<{ width: number }>`
align-items: center;
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledEmptyTd = styled.td`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
border-right: none;
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(1)};
height: 32px;
width: ${({ width }) => width}px;
min-width: ${({ width }) => width}px;
position: sticky;
left: 48px;
`;
const StyledTag = styled(Tag)`
flex-shrink: 0;
`;
const StyledPlusButtonPlaceholderCell = styled.div`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
height: 32px;
min-width: 32px;
width: 32px;
`;
const StyledFieldPlaceholderCell = styled.div<{ widthOfFields: number }>`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
height: 32px;
min-width: ${({ widthOfFields }) => widthOfFields}px;
width: ${({ widthOfFields }) => widthOfFields}px;
`;
export const RecordTableRecordGroupSection = () => {
const theme = useTheme();
const currentRecordGroupId = useCurrentRecordGroupId();
const { visibleRecordFields, objectMetadataItem } =
useRecordTableContextOrThrow();
const { objectMetadataItem } = useRecordTableContextOrThrow();
const { aggregateValue, aggregateLabel } =
useAggregateRecordsForRecordTableSection();
const { labelIdentifierFieldMetadataItem } = useRecordIndexContextOrThrow();
const visibleRecordFields = useRecoilComponentValue(
visibleRecordFieldsComponentSelector,
);
const widthOfLabelIdentifierRecordField =
visibleRecordFields.find(
findByProperty(
'fieldMetadataItemId',
labelIdentifierFieldMetadataItem?.id ?? '',
),
)?.size ?? null;
const [
isRecordGroupTableSectionToggled,
setIsRecordGroupTableSectionToggled,
@@ -77,13 +130,30 @@ export const RecordTableRecordGroupSection = () => {
setIsRecordGroupTableSectionToggled((prevState) => !prevState);
}, [setIsRecordGroupTableSectionToggled]);
const visibleRecordFieldsWithoutLabelIdentifier = visibleRecordFields.filter(
filterOutByProperty(
'fieldMetadataItemId',
labelIdentifierFieldMetadataItem?.id,
),
);
const sumOfWidthOfVisibleRecordFieldsAfterLabelIdentifierField =
visibleRecordFieldsWithoutLabelIdentifier.reduce(sumByProperty('size'), 0);
const sumOfBorderWidthForFields =
visibleRecordFieldsWithoutLabelIdentifier.length;
const fieldsPlaceholderWidth =
sumOfWidthOfVisibleRecordFieldsAfterLabelIdentifierField +
sumOfBorderWidthForFields;
if (!isDefined(recordGroup)) {
return null;
}
return (
<StyledTrContainer onClick={handleDropdownToggle}>
<td aria-hidden />
<StyledDragDropHeaderPlaceholder />
<StyledChevronContainer>
<StyledAnimatedLightIconButton
Icon={IconChevronDown}
@@ -93,7 +163,10 @@ export const RecordTableRecordGroupSection = () => {
transition={{ duration: theme.animation.duration.normal }}
/>
</StyledChevronContainer>
<StyledRecordGroupSection className="disable-shadow">
<StyledRecordGroupSection
className="disable-shadow"
width={widthOfLabelIdentifierRecordField ?? 104}
>
<StyledTag
variant={
recordGroup.type !== RecordGroupDefinitionType.NoValue
@@ -116,9 +189,8 @@ export const RecordTableRecordGroupSection = () => {
/>
<RecordTableRecordGroupStickyEffect />
</StyledRecordGroupSection>
<StyledEmptyTd colSpan={visibleRecordFields.length - 1} />
<StyledEmptyTd />
<StyledEmptyTd />
<StyledFieldPlaceholderCell widthOfFields={fieldsPlaceholderWidth} />
<StyledPlusButtonPlaceholderCell />
</StyledTrContainer>
);
};
@@ -0,0 +1,5 @@
export const filterOutByProperty = <T, K extends keyof T>(property: K, valueToExclude: T[K] | null | undefined) => {
return (itemToFilter: T) => {
return itemToFilter[property] !== valueToExclude
}
}
@@ -1,4 +1,4 @@
export const findByProperty = <T, K extends keyof T>(property: K, valueToMatch: T[K]) => {
export const findByProperty = <T, K extends keyof T>(property: K, valueToMatch: T[K] | null | undefined) => {
return (itemToFind: T) => {
return itemToFind[property] === valueToMatch
}
@@ -0,0 +1,17 @@
import { isNumberOrNaN } from "@sniptt/guards";
export const sumByProperty = <T, K extends keyof T>(property: K) => {
return (accumulator: number, nextItem: T) => {
if(typeof accumulator !== "number") {
accumulator = 0;
}
if(!isNumberOrNaN(nextItem[property])) {
return accumulator;
}
accumulator += nextItem[property];
return accumulator
}
}
@@ -7,8 +7,10 @@
* |___/
*/
export { filterOutByProperty } from './array/filterOutByProperty';
export { findById } from './array/findById';
export { findByProperty } from './array/findByProperty';
export { sumByProperty } from './array/sumByProperty';
export { assertUnreachable } from './assertUnreachable';
export { deepMerge } from './deepMerge';
export { extractAndSanitizeObjectStringFields } from './extractAndSanitizeObjectStringFields';