Fixed table header resize bugs (#14401)

This PR fixes the main resize bugs in
https://github.com/twentyhq/core-team-issues/issues/1453

Changes :

- A new `RecordTableResizeEffect` has been created to modify the last
column width to compensate for the resize width dynamically, because of
flew-wrap on the table elements, we cannot use width: 100% everywhere
and we have to set the width of everything.
- CSS transitions have been removed because they were degrading both the
performance and the UX when resizing
- Created a common `RecordTableHeaderCellContainer`, this was needed to
factorize the various table header components
- Created a `RecordTableHeaderLabelIdentifierCellPlusButton` component
to ease the reading of `RecordTableHeaderLabelIdentifierCell`
- Put the CSS of the blue line while resizing in the
`RecordTableHeaderResizeHandler` component to avoid duplicating it
everywhere.
- Extracted `COLUMN_MIN_WIDTH` in a constant file

Fixes https://github.com/twentyhq/core-team-issues/issues/1453
This commit is contained in:
Lucas Bordeau
2025-09-10 17:04:47 +00:00
committed by GitHub
parent fb43827031
commit 8d10e738ac
23 changed files with 364 additions and 316 deletions
@@ -24,7 +24,7 @@ export const RecordTable = () => {
objectMetadataItem.id,
);
const tableBodyRef = useRef<HTMLTableElement>(null);
const tableBodyRef = useRef<HTMLDivElement>(null);
const { toggleClickOutside } = useClickOutsideListener(
RECORD_TABLE_CLICK_OUTSIDE_LISTENER_ID,
@@ -7,7 +7,7 @@ import { RecordTableRecordGroupBodyEffects } from '@/object-record/record-table/
export interface RecordTableBodyEffectsWrapperProps {
hasRecordGroups: boolean;
tableBodyRef: React.RefObject<HTMLTableElement>;
tableBodyRef: React.RefObject<HTMLDivElement>;
}
export const RecordTableBodyEffectsWrapper = ({
@@ -1,3 +1,4 @@
import { RecordTableResizeEffect } from '@/object-record/record-table/components/RecordTableResizeEffect';
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';
@@ -23,6 +24,7 @@ const StyledTable = styled.div<{
display: flex;
flex-wrap: wrap;
width: 100%;
div.header-cell {
position: sticky;
@@ -36,7 +38,6 @@ const StyledTable = styled.div<{
div.header-cell:nth-of-type(1) {
left: 0px;
transition: 0.3s ease;
background-color: ${({ theme }) => theme.background.primary};
z-index: ${TABLE_Z_INDEX.headerColumnsSticky};
@@ -46,7 +47,6 @@ const StyledTable = styled.div<{
left: 16px;
top: 0;
transition: 0.3s ease;
background-color: ${({ theme }) => theme.background.primary};
z-index: ${TABLE_Z_INDEX.headerColumnsSticky};
@@ -56,7 +56,6 @@ const StyledTable = styled.div<{
left: 48px;
right: 0;
transition: 0.3s ease;
background-color: ${({ theme }) => theme.background.primary};
z-index: ${TABLE_Z_INDEX.headerColumnsSticky};
@@ -143,7 +142,7 @@ const StyledTableContainer = styled.div`
`;
export interface RecordTableContentProps {
tableBodyRef: React.RefObject<HTMLTableElement>;
tableBodyRef: React.RefObject<HTMLDivElement>;
handleDragSelectionStart: () => void;
handleDragSelectionEnd: () => void;
hasRecordGroups: boolean;
@@ -197,6 +196,7 @@ export const RecordTableContent = ({
<RecordTableNoRecordGroupBody />
)}
<RecordTableScrollAndZIndexEffect />
<RecordTableResizeEffect />
</StyledTable>
<DragSelect
selectableItemsContainerRef={containerRef}
@@ -9,7 +9,7 @@ const StyledEmptyStateContainer = styled.div`
`;
export interface RecordTableEmptyProps {
tableBodyRef: React.RefObject<HTMLTableElement>;
tableBodyRef: React.RefObject<HTMLDivElement>;
}
export const RecordTableEmpty = ({ tableBodyRef }: RecordTableEmptyProps) => (
@@ -0,0 +1,40 @@
import { recordTableWidthComponentState } from '@/object-record/record-table/states/recordTableWidthComponentState';
import { useScrollWrapperElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperElement';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useEffect } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const RecordTableResizeEffect = () => {
const setRecordTableWidth = useSetRecoilComponentState(
recordTableWidthComponentState,
);
const { scrollWrapperHTMLElement } = useScrollWrapperElement();
useEffect(() => {
const tableWidth = scrollWrapperHTMLElement?.clientWidth ?? 0;
setRecordTableWidth(tableWidth);
const tableResizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.target === scrollWrapperHTMLElement) {
const newWidth = scrollWrapperHTMLElement.clientWidth;
setRecordTableWidth(newWidth);
}
}
});
if (isDefined(scrollWrapperHTMLElement)) {
tableResizeObserver.observe(scrollWrapperHTMLElement);
}
return () => {
tableResizeObserver.disconnect();
};
}, [setRecordTableWidth, scrollWrapperHTMLElement]);
return null;
};
@@ -0,0 +1 @@
export const COLUMN_MIN_WIDTH = 48;
@@ -0,0 +1,41 @@
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { recordTableWidthComponentState } from '@/object-record/record-table/states/recordTableWidthComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { sumByProperty } from 'twenty-shared/utils';
// TODO: use this everywhere and extract in files
export const RECORD_TABLE_DRAG_DROP_COLUMN_WIDTH = 16;
export const RECORD_TABLE_CHECKBOX_COLUMN_WIDTH = 32;
export const RECORD_TABLE_PLUS_BUTTON_COLUMN_WIDTH = 32;
export const useRecordTableLastColumnWidthToFill = () => {
const { visibleRecordFields } = useRecordTableContextOrThrow();
const recordTableWidth = useRecoilComponentValue(
recordTableWidthComponentState,
);
const totalColumnsWidth = visibleRecordFields.reduce(
sumByProperty('size'),
0,
);
const widthOfBorders = visibleRecordFields.length;
const fixedColumnsWidth =
RECORD_TABLE_DRAG_DROP_COLUMN_WIDTH +
RECORD_TABLE_CHECKBOX_COLUMN_WIDTH +
RECORD_TABLE_PLUS_BUTTON_COLUMN_WIDTH +
widthOfBorders;
const remainingWidthToFill = Math.max(
0,
recordTableWidth - fixedColumnsWidth - totalColumnsWidth,
);
const width = remainingWidthToFill > 0 ? remainingWidthToFill : 0;
return {
lastColumnWidth: width,
};
};
@@ -20,7 +20,6 @@ export const RecordTableBodyDroppable = ({
isDropDisabled,
}: RecordTableBodyDroppableProps) => {
const [v4Persistable] = useState(v4());
const recordTableBodyId = `record-table-body${recordGroupId ? '-' + recordGroupId : ''}`;
const setRecordTableHoverPosition = useSetRecoilComponentState(
recordTableHoverPositionComponentState,
@@ -42,19 +41,20 @@ export const RecordTableBodyDroppable = ({
isDropDisabled={isDropDisabled}
>
{(provided) => (
<RecordTableBody
id={recordTableBodyId}
ref={provided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
onMouseLeave={handleMouseLeave}
>
<RecordTableBodyDroppableContextProvider
value={{ droppablePlaceholder: provided.placeholder }}
<>
<RecordTableBody
ref={provided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
onMouseLeave={handleMouseLeave}
>
{children}
</RecordTableBodyDroppableContextProvider>
</RecordTableBody>
<RecordTableBodyDroppableContextProvider
value={{ droppablePlaceholder: provided.placeholder }}
>
{children}
</RecordTableBodyDroppableContextProvider>
</RecordTableBody>
</>
)}
</Droppable>
);
@@ -1,8 +1,27 @@
import { useRecordTableRowContextOrThrow } from '@/object-record/record-table/contexts/RecordTableRowContext';
import { useRecordTableLastColumnWidthToFill } from '@/object-record/record-table/hooks/useRecordTableLastColumnWidthToFill';
import { RecordTableTd } from '@/object-record/record-table/record-table-cell/components/RecordTableTd';
import { resizeFieldOffsetComponentState } from '@/object-record/record-table/states/resizeFieldOffsetComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
export const RecordTableLastEmptyCell = () => {
const { isSelected } = useRecordTableRowContextOrThrow();
const { lastColumnWidth } = useRecordTableLastColumnWidthToFill();
return <RecordTableTd isSelected={isSelected} hasRightBorder={false} />;
const resizeFieldOffset = useRecoilComponentValue(
resizeFieldOffsetComponentState,
);
const width =
resizeFieldOffset > 0
? lastColumnWidth + resizeFieldOffset
: lastColumnWidth;
return (
<RecordTableTd
isSelected={isSelected}
hasRightBorder={false}
width={width}
/>
);
};
@@ -0,0 +1,15 @@
import { useRecordTableRowContextOrThrow } from '@/object-record/record-table/contexts/RecordTableRowContext';
import { RECORD_TABLE_PLUS_BUTTON_COLUMN_WIDTH } from '@/object-record/record-table/hooks/useRecordTableLastColumnWidthToFill';
import { RecordTableTd } from '@/object-record/record-table/record-table-cell/components/RecordTableTd';
export const RecordTablePlusButtonCellPlaceholder = () => {
const { isSelected } = useRecordTableRowContextOrThrow();
return (
<RecordTableTd
isSelected={isSelected}
hasRightBorder={false}
width={RECORD_TABLE_PLUS_BUTTON_COLUMN_WIDTH}
/>
);
};
@@ -21,7 +21,6 @@ const StyledColumnFooterCell = styled.div<{
width: ${columnWidth}px;
`}
text-align: left;
transition: 0.3s ease;
${({ theme }) => {
return `
&:hover {
@@ -5,13 +5,11 @@ import { RecordTableHeaderPlusButtonContent } from '@/object-record/record-table
import { isRecordTableRowActiveComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowActiveComponentFamilyState';
import { isRecordTableRowFocusedComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowFocusedComponentFamilyState';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useScrollWrapperElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperElement';
import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue';
import { useTheme } from '@emotion/react';
import { IconPlus } from 'twenty-ui/display';
const StyledPlusIconHeaderCell = styled.div<{
isTableWiderThanScreen: boolean;
isFirstRowActiveOrFocused: boolean;
}>`
border-bottom: ${({ isFirstRowActiveOrFocused, theme }) =>
@@ -53,12 +51,6 @@ const StyledDropdownContainer = styled.div`
export const RecordTableHeaderAddColumnButton = () => {
const theme = useTheme();
const { scrollWrapperHTMLElement } = useScrollWrapperElement();
const isTableWiderThanScreen =
(scrollWrapperHTMLElement?.clientWidth ?? 0) <
(scrollWrapperHTMLElement?.scrollWidth ?? 0);
const isFirstRowActive = useRecoilComponentFamilyValue(
isRecordTableRowActiveComponentFamilyState,
0,
@@ -73,7 +65,6 @@ export const RecordTableHeaderAddColumnButton = () => {
return (
<StyledPlusIconHeaderCell
isTableWiderThanScreen={isTableWiderThanScreen}
isFirstRowActiveOrFocused={isFirstRowActiveOrFocused}
className="header-cell"
>
@@ -1,8 +1,8 @@
import styled from '@emotion/styled';
import { type RecordField } from '@/object-record/record-field/types/RecordField';
import { COLUMN_MIN_WIDTH } from '@/object-record/record-table/constants/ColumnMinWidth';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableColumnHeadWithDropdown } from '@/object-record/record-table/record-table-header/components/RecordTableColumnHeadWithDropdown';
import { RecordTableHeaderCellContainer } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderCellContainer';
import { RecordTableHeaderResizeHandler } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderResizeHandler';
import { isRecordTableRowActiveComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowActiveComponentFamilyState';
import { isRecordTableRowFocusedComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowFocusedComponentFamilyState';
@@ -11,71 +11,6 @@ import { resizeFieldOffsetComponentState } from '@/object-record/record-table/st
import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
const COLUMN_MIN_WIDTH = 48;
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;
}
`;
type RecordTableHeaderCellProps = {
recordField: RecordField;
};
@@ -117,7 +52,7 @@ export const RecordTableHeaderCell = ({
const isFirstRowActiveOrFocused = isFirstRowActive || isFirstRowFocused;
return (
<StyledColumnHeaderCell
<RecordTableHeaderCellContainer
className="header-cell"
key={recordField.fieldMetadataItemId}
isResizing={
@@ -126,13 +61,11 @@ export const RecordTableHeaderCell = ({
columnWidth={columnWidth}
isFirstRowActiveOrFocused={isFirstRowActiveOrFocused}
>
<StyledColumnHeadContainer>
<RecordTableColumnHeadWithDropdown
recordField={recordField}
objectMetadataId={objectMetadataItem.id}
/>
</StyledColumnHeadContainer>
<RecordTableColumnHeadWithDropdown
recordField={recordField}
objectMetadataId={objectMetadataItem.id}
/>
<RecordTableHeaderResizeHandler recordField={recordField} />
</StyledColumnHeaderCell>
</RecordTableHeaderCellContainer>
);
};
@@ -0,0 +1,54 @@
import styled from '@emotion/styled';
const StyledHeaderCell = 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;
}`;
}
}};
z-index: ${({ zIndex }) => zIndex ?? 'auto'};
`;
export const RecordTableHeaderCellContainer = StyledHeaderCell;
@@ -1,12 +1,13 @@
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 { RecordTableHeaderCellContainer } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderCellContainer';
import { COLUMN_MIN_WIDTH } from '@/object-record/record-table/constants/ColumnMinWidth';
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';
@@ -17,73 +18,6 @@ import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-st
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();
@@ -124,7 +58,7 @@ export const RecordTableHeaderFirstScrollableCell = () => {
const computedDynamicWidth = baseWidth + widthOffsetWhileResizing;
const columnWidth = Math.max(computedDynamicWidth, COLUMN_RESIZE_MIN_WIDTH);
const columnWidth = Math.max(computedDynamicWidth, COLUMN_MIN_WIDTH);
const isFirstRowActiveOrFocused = isFirstRowActive || isFirstRowFocused;
@@ -151,7 +85,7 @@ export const RecordTableHeaderFirstScrollableCell = () => {
}
return (
<StyledColumnHeaderCell
<RecordTableHeaderCellContainer
className="header-cell"
key={recordField.fieldMetadataItemId}
isResizing={
@@ -161,13 +95,11 @@ export const RecordTableHeaderFirstScrollableCell = () => {
isFirstRowActiveOrFocused={isFirstRowActiveOrFocused}
zIndex={zIndex}
>
<StyledColumnHeadContainer>
<RecordTableColumnHeadWithDropdown
recordField={recordField}
objectMetadataId={objectMetadataItem.id}
/>
</StyledColumnHeadContainer>
<RecordTableColumnHeadWithDropdown
recordField={recordField}
objectMetadataId={objectMetadataItem.id}
/>
<RecordTableHeaderResizeHandler recordField={recordField} />
</StyledColumnHeaderCell>
</RecordTableHeaderCellContainer>
);
};
@@ -1,102 +1,40 @@
import styled from '@emotion/styled';
import { useState } from 'react';
import { isObjectMetadataReadOnly } from '@/object-record/read-only/utils/isObjectMetadataReadOnly';
import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView';
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 { RecordTableHeaderCellContainer } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderCellContainer';
import { COLUMN_MIN_WIDTH } from '@/object-record/record-table/constants/ColumnMinWidth';
import { RecordTableHeaderLabelIdentifierCellPlusButton } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderLabelIdentifierCellPlusButton';
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 { useState } from 'react';
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;
overflow: hidden;
& > :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 } =
const { objectMetadataItem, visibleRecordFields } =
useRecordTableContextOrThrow();
const [iconIsVisible, setIconIsVisible] = useState(false);
const isMobile = useIsMobile();
const isFirstRowActive = useRecoilComponentFamilyValue(
isRecordTableRowActiveComponentFamilyState,
0,
@@ -113,10 +51,6 @@ export const RecordTableHeaderLabelIdentifierCell = () => {
findByProperty('fieldMetadataItemId', labelIdentifierFieldMetadataItem?.id),
);
const { createNewIndexRecord } = useCreateNewIndexRecord({
objectMetadataItem,
});
const resizeFieldOffset = useRecoilComponentValue(
resizeFieldOffsetComponentState,
);
@@ -125,25 +59,10 @@ export const RecordTableHeaderLabelIdentifierCell = () => {
resizedFieldMetadataIdComponentState,
);
const hasAnySoftDeleteFilterOnView = useRecoilComponentValue(
hasAnySoftDeleteFilterOnViewComponentSelector,
);
if (!recordField) {
return <></>;
}
const handlePlusButtonClick = () => {
createNewIndexRecord();
};
const isReadOnly = isObjectMetadataReadOnly({
objectPermissions,
objectMetadataItem,
});
const hasObjectUpdatePermissions = objectPermissions.canUpdateObjectRecords;
const widthOffsetWhileResizing =
resizedFieldMetadataItemId === recordField.fieldMetadataItemId
? resizeFieldOffset
@@ -153,12 +72,12 @@ export const RecordTableHeaderLabelIdentifierCell = () => {
const computedDynamicWidth = baseWidth + widthOffsetWhileResizing;
const columnWidth = Math.max(computedDynamicWidth, COLUMN_RESIZE_MIN_WIDTH);
const columnWidth = Math.max(computedDynamicWidth, COLUMN_MIN_WIDTH);
const isFirstRowActiveOrFocused = isFirstRowActive || isFirstRowFocused;
return (
<StyledColumnHeaderCell
<RecordTableHeaderCellContainer
className="header-cell"
key={recordField.fieldMetadataItemId}
isResizing={
@@ -174,21 +93,9 @@ export const RecordTableHeaderLabelIdentifierCell = () => {
recordField={recordField}
objectMetadataId={objectMetadataItem.id}
/>
{(isMobile || iconIsVisible) &&
!isReadOnly &&
hasObjectUpdatePermissions &&
!hasAnySoftDeleteFilterOnView && (
<StyledHeaderIcon>
<LightIconButton
Icon={IconPlus}
size="small"
accent="tertiary"
onClick={handlePlusButtonClick}
/>
</StyledHeaderIcon>
)}
{iconIsVisible && <RecordTableHeaderLabelIdentifierCellPlusButton />}
</StyledColumnHeadContainer>
<RecordTableHeaderResizeHandler recordField={recordField} />
</StyledColumnHeaderCell>
</RecordTableHeaderCellContainer>
);
};
@@ -0,0 +1,55 @@
import { isObjectMetadataReadOnly } from '@/object-record/read-only/utils/isObjectMetadataReadOnly';
import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import styled from '@emotion/styled';
import { IconPlus } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { useIsMobile } from 'twenty-ui/utilities';
const StyledHeaderIcon = styled.div`
margin: ${({ theme }) => theme.spacing(1, 1, 1, 1.5)};
`;
export const RecordTableHeaderLabelIdentifierCellPlusButton = () => {
const { objectMetadataItem, objectPermissions } =
useRecordTableContextOrThrow();
const isMobile = useIsMobile();
const { createNewIndexRecord } = useCreateNewIndexRecord({
objectMetadataItem,
});
const handlePlusButtonClick = () => {
createNewIndexRecord();
};
const isReadOnly = isObjectMetadataReadOnly({
objectPermissions,
objectMetadataItem,
});
const hasAnySoftDeleteFilterOnView = useRecoilComponentValue(
hasAnySoftDeleteFilterOnViewComponentSelector,
);
const hasObjectUpdatePermissions = objectPermissions.canUpdateObjectRecords;
return (
!isMobile &&
!isReadOnly &&
hasObjectUpdatePermissions &&
!hasAnySoftDeleteFilterOnView && (
<StyledHeaderIcon>
<LightIconButton
Icon={IconPlus}
size="small"
accent="tertiary"
onClick={handlePlusButtonClick}
/>
</StyledHeaderIcon>
)
);
};
@@ -1,19 +1,29 @@
import { useRecordTableLastColumnWidthToFill } from '@/object-record/record-table/hooks/useRecordTableLastColumnWidthToFill';
import { resizeFieldOffsetComponentState } from '@/object-record/record-table/states/resizeFieldOffsetComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import styled from '@emotion/styled';
const StyledLastColumnHeader = styled.div`
const StyledLastColumnHeader = styled.div<{ width: number }>`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
background-color: ${({ theme }) => theme.background.primary};
border-left: none !important;
color: ${({ theme }) => theme.font.color.tertiary};
width: fit-content;
height: 32px;
max-height: 32px;
width: ${({ width }) => width}px;
`;
export const RecordTableHeaderLastColumn = () => {
return (
<StyledLastColumnHeader className="header-cell"></StyledLastColumnHeader>
const { lastColumnWidth } = useRecordTableLastColumnWidthToFill();
const resizeFieldOffset = useRecoilComponentValue(
resizeFieldOffsetComponentState,
);
const width = lastColumnWidth - resizeFieldOffset;
return <StyledLastColumnHeader className="header-cell" width={width} />;
};
@@ -1,10 +1,11 @@
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 { useDragSelect } from '@/ui/utilities/drag-select/hooks/useDragSelect';
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import styled from '@emotion/styled';
import { useIsMobile } from 'twenty-ui/utilities';
const StyledResizeHandler = styled.div`
const StyledResizeHandler = styled.div<{ isResizing: boolean }>`
bottom: 0;
cursor: col-resize;
padding: 0 ${({ theme }) => theme.spacing(2)};
@@ -13,6 +14,21 @@ const StyledResizeHandler = styled.div`
top: 0;
width: 3px;
z-index: 1;
${({ isResizing, theme }) => {
if (isResizing === true) {
return `&:after {
background-color: ${theme.color.blue};
bottom: 0;
content: '';
display: block;
position: absolute;
right: 8px;
top: 0;
width: 2px;
}`;
}
}};
`;
export const RecordTableHeaderResizeHandler = ({
@@ -24,18 +40,26 @@ export const RecordTableHeaderResizeHandler = ({
const columnResizeDisabled = isMobile;
const setResizedFieldMetadataItemId = useSetRecoilComponentState(
resizedFieldMetadataIdComponentState,
);
const [resizedFieldMetadataItemId, setResizedFieldMetadataItemId] =
useRecoilComponentState(resizedFieldMetadataIdComponentState);
const isResizing =
recordField.fieldMetadataItemId === resizedFieldMetadataItemId;
const { setDragSelectionStartEnabled } = useDragSelect();
const handlePointerDown = () => {
setDragSelectionStartEnabled(false);
setResizedFieldMetadataItemId(recordField.fieldMetadataItemId);
};
return (
!columnResizeDisabled && (
<StyledResizeHandler
className="cursor-col-resize"
role="separator"
onPointerDown={() => {
setResizedFieldMetadataItemId(recordField.fieldMetadataItemId);
}}
onPointerDown={handlePointerDown}
isResizing={isResizing}
/>
)
);
@@ -1,7 +1,11 @@
import { useUpdateRecordField } from '@/object-record/record-field/hooks/useUpdateRecordField';
import { COLUMN_MIN_WIDTH } from '@/object-record/record-table/constants/ColumnMinWidth';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection';
import { resizedFieldMetadataIdComponentState } from '@/object-record/record-table/states/resizedFieldMetadataIdComponentState';
import { resizeFieldOffsetComponentState } from '@/object-record/record-table/states/resizeFieldOffsetComponentState';
import { useDragSelect } from '@/ui/utilities/drag-select/hooks/useDragSelect';
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';
@@ -13,8 +17,6 @@ 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();
@@ -33,33 +35,48 @@ export const useResizeTableHeader = () => {
const [resizedFieldMetadataItemId, setResizedFieldMetadataItemId] =
useRecoilComponentState(resizedFieldMetadataIdComponentState);
const recordField = visibleRecordFields.find(
findByProperty('fieldMetadataItemId', resizedFieldMetadataItemId),
);
const { resetTableRowSelection } = useResetTableRowSelection();
const { saveRecordFields } = useSaveRecordFields();
const { updateRecordField } = useUpdateRecordField();
const handleResizeHandlerStart = useCallback<PointerEventListener>(
({ x }) => {
resetTableRowSelection();
setInitialPointerPositionX(x);
},
[],
[resetTableRowSelection],
);
const handleResizeHandlerMove = useCallback<PointerEventListener>(
({ x }) => {
if (!initialPointerPositionX) return;
throwIfNotDefined(recordField, 'recordField');
const newResizeOffset = x - initialPointerPositionX;
const newRecordFieldSizeWithOffset = recordField.size + newResizeOffset;
if (newRecordFieldSizeWithOffset < COLUMN_MIN_WIDTH) {
return;
}
setResizeFieldOffset(x - initialPointerPositionX);
},
[setResizeFieldOffset, initialPointerPositionX],
[setResizeFieldOffset, initialPointerPositionX, recordField],
);
const { setDragSelectionStartEnabled } = useDragSelect();
const handleResizeHandlerEnd = useRecoilCallback(
({ snapshot, set }) =>
async () => {
const recordField = visibleRecordFields.find(
findByProperty('fieldMetadataItemId', resizedFieldMetadataItemId),
);
throwIfNotDefined(recordField, 'recordField');
if (!resizedFieldMetadataItemId) return;
@@ -70,10 +87,7 @@ export const useResizeTableHeader = () => {
);
const nextWidth = Math.round(
Math.max(
recordField.size + resizeFieldOffset,
COLUMN_RESIZE_MIN_WIDTH,
),
Math.max(recordField.size + resizeFieldOffset, COLUMN_MIN_WIDTH),
);
set(resizeFieldOffsetCallbackState, 0);
@@ -90,6 +104,8 @@ export const useResizeTableHeader = () => {
saveRecordFields([updatedRecordField]);
}
setDragSelectionStartEnabled(true);
},
[
saveRecordFields,
@@ -97,7 +113,8 @@ export const useResizeTableHeader = () => {
resizeFieldOffsetCallbackState,
setResizedFieldMetadataItemId,
updateRecordField,
visibleRecordFields,
setDragSelectionStartEnabled,
recordField,
],
);
@@ -2,6 +2,7 @@ import { useRecordIndexContextOrThrow } from '@/object-record/record-index/conte
import { RecordTableCellCheckbox } from '@/object-record/record-table/record-table-cell/components/RecordTableCellCheckbox';
import { RecordTableCellGrip } from '@/object-record/record-table/record-table-cell/components/RecordTableCellGrip';
import { RecordTableLastEmptyCell } from '@/object-record/record-table/record-table-cell/components/RecordTableLastEmptyCell';
import { RecordTablePlusButtonCellPlaceholder } from '@/object-record/record-table/record-table-cell/components/RecordTablePlusButtonCellPlaceholder';
import { RecordTableCells } from '@/object-record/record-table/record-table-row/components/RecordTableCells';
import { RecordTableDraggableTr } from '@/object-record/record-table/record-table-row/components/RecordTableDraggableTr';
import { RecordTableRowArrowKeysEffect } from '@/object-record/record-table/record-table-row/components/RecordTableRowArrowKeysEffect';
@@ -51,6 +52,7 @@ export const RecordTableRow = ({
<RecordTableCellGrip />
<RecordTableCellCheckbox />
<RecordTableCells />
<RecordTablePlusButtonCellPlaceholder />
<RecordTableLastEmptyCell />
<ListenRecordUpdatesEffect
objectNameSingular={objectNameSingular}
@@ -0,0 +1,8 @@
import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
export const recordTableWidthComponentState = createComponentState<number>({
key: 'recordTableWidthComponentState',
defaultValue: 0,
componentInstanceContext: RecordTableComponentInstanceContext,
});
@@ -1,11 +1,11 @@
import styled from '@emotion/styled';
import { type RefObject, useCallback, useState } from 'react';
import { useDragSelect } from '@/ui/utilities/drag-select/hooks/useDragSelect';
import { useDragSelectWithAutoScroll } from '@/ui/utilities/drag-select/hooks/useDragSelectWithAutoScroll';
import { useTrackPointer } from '@/ui/utilities/pointer-event/hooks/useTrackPointer';
import { isDefined } from 'twenty-shared/utils';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { useDragSelect } from '../hooks/useDragSelect';
import { type SelectionBox } from '../types/SelectionBox';
import { isValidSelectionStart } from '../utils/selectionBoxValidation';