5421 box shadow on frozen header and first column (#6130)

- Refactored components in table
- Added a isTableRecordScrolledLeftState and
isTableRecordScrolledTopState to subscribe to table scroll
- Added a zIndex logic that subscribes to those new states in new tinier
components

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Lucas Bordeau 2024-07-05 18:30:59 +02:00 committed by GitHub
parent cc6ce142ce
commit 7b3a590f79
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
68 changed files with 1531 additions and 1130 deletions

View File

@ -1,14 +1,7 @@
module.exports = {
root: true,
extends: ['plugin:prettier/recommended'],
plugins: [
'@nx',
'prefer-arrow',
'import',
'simple-import-sort',
'unused-imports',
'unicorn',
],
plugins: ['@nx', 'prefer-arrow', 'import', 'unused-imports', 'unicorn'],
rules: {
'func-style': ['error', 'declaration', { allowArrowFunctions: true }],
'no-console': ['warn', { allow: ['group', 'groupCollapsed', 'groupEnd'] }],
@ -53,26 +46,6 @@ module.exports = {
},
],
'simple-import-sort/imports': [
'error',
{
groups: [
// Packages
['^react', '^@?\\w'],
// Internal modules
['^(@|~|src|@ui)(/.*|$)'],
// Side effect imports
['^\\u0000'],
// Relative imports
['^\\.\\.(?!/?$)', '^\\.\\./?$'],
['^\\./(?=.*/)(?!/?$)', '^\\.(?!/?$)', '^\\./?$'],
// CSS imports
['^.+\\.?(css)$'],
],
},
],
'simple-import-sort/exports': 'error',
'unused-imports/no-unused-imports': 'warn',
'unused-imports/no-unused-vars': [
'warn',

View File

@ -5,21 +5,24 @@
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.addMissingImports": "always"
"source.addMissingImports": "always",
"source.organizeImports": "always"
}
},
"[javascript]": {
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.addMissingImports": "always"
"source.addMissingImports": "always",
"source.organizeImports": "always"
}
},
"[typescriptreact]": {
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.addMissingImports": "always"
"source.addMissingImports": "always",
"source.organizeImports": "always"
}
},
"[json]": {

View File

@ -9,6 +9,7 @@ import { FieldContext } from '../contexts/FieldContext';
export const useIsFieldEmpty = () => {
const { entityId, fieldDefinition, overridenIsFieldEmpty } =
useContext(FieldContext);
const fieldValue = useRecordFieldValue(
entityId,
fieldDefinition?.metadata?.fieldName ?? '',

View File

@ -5,7 +5,7 @@ import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModa
import { useCombinedViewSorts } from '@/views/hooks/useCombinedViewSorts';
import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
export const RemoveSortingModal = ({
export const RecordIndexRemoveSortingModal = ({
recordTableId,
}: {
recordTableId: string;

View File

@ -1,8 +1,8 @@
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { RecordUpdateHookParams } from '@/object-record/record-field/contexts/FieldContext';
import { RecordIndexRemoveSortingModal } from '@/object-record/record-index/components/RecordIndexRemoveSortingModal';
import { RecordTableActionBar } from '@/object-record/record-table/action-bar/components/RecordTableActionBar';
import { RecordTableWithWrappers } from '@/object-record/record-table/components/RecordTableWithWrappers';
import { RemoveSortingModal } from '@/object-record/record-table/components/RemoveSortingModal';
import { RecordTableContextMenu } from '@/object-record/record-table/context-menu/components/RecordTableContextMenu';
type RecordIndexTableContainerProps = {
@ -39,7 +39,7 @@ export const RecordIndexTableContainer = ({
createRecord={createRecord}
/>
<RecordTableActionBar recordTableId={recordTableId} />
<RemoveSortingModal recordTableId={recordTableId} />
<RecordIndexRemoveSortingModal recordTableId={recordTableId} />
<RecordTableContextMenu recordTableId={recordTableId} />
</>
);

View File

@ -1,8 +1,8 @@
import styled from '@emotion/styled';
import { IconComponent } from 'twenty-ui';
import { AnimatedContainer } from '@/object-record/record-table/components/AnimatedContainer';
import { FloatingIconButton } from '@/ui/input/button/components/FloatingIconButton';
import { AnimatedContainer } from '@/ui/utilities/animation/components/AnimatedContainer';
const StyledInlineCellButtonContainer = styled.div`
align-items: center;

View File

@ -1,29 +0,0 @@
import styled from '@emotion/styled';
import { IconListViewGrip } from '@/ui/input/components/IconListViewGrip';
const StyledContainer = styled.div`
cursor: grab;
width: 16px;
height: 32px;
z-index: 200;
display: flex;
&:hover .icon {
opacity: 1;
}
`;
const StyledIconWrapper = styled.div<{ isDragging: boolean }>`
opacity: ${({ isDragging }) => (isDragging ? 1 : 0)};
transition: opacity 0.1s;
`;
export const GripCell = ({ isDragging }: { isDragging: boolean }) => {
return (
<StyledContainer>
<StyledIconWrapper className="icon" isDragging={isDragging}>
<IconListViewGrip />
</StyledIconWrapper>
</StyledContainer>
);
};

View File

@ -1,154 +1,20 @@
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { MOBILE_VIEWPORT, RGBA } from 'twenty-ui';
import { isNonEmptyString } from '@sniptt/guards';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { RecordTableBody } from '@/object-record/record-table/components/RecordTableBody';
import { RecordTableBodyEffect } from '@/object-record/record-table/components/RecordTableBodyEffect';
import { RecordTableHeader } from '@/object-record/record-table/components/RecordTableHeader';
import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext';
import { useHandleContainerMouseEnter } from '@/object-record/record-table/hooks/internal/useHandleContainerMouseEnter';
import { RecordTableBody } from '@/object-record/record-table/record-table-body/components/RecordTableBody';
import { RecordTableBodyEffect } from '@/object-record/record-table/record-table-body/components/RecordTableBodyEffect';
import { RecordTableContextProvider } from '@/object-record/record-table/components/RecordTableContextProvider';
import { RecordTableHeader } from '@/object-record/record-table/record-table-header/components/RecordTableHeader';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { useRecordTableMoveFocus } from '@/object-record/record-table/hooks/useRecordTableMoveFocus';
import { useCloseRecordTableCellV2 } from '@/object-record/record-table/record-table-cell/hooks/useCloseRecordTableCellV2';
import { useMoveSoftFocusToCellOnHoverV2 } from '@/object-record/record-table/record-table-cell/hooks/useMoveSoftFocusToCellOnHoverV2';
import {
OpenTableCellArgs,
useOpenRecordTableCellV2,
} from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellV2';
import { useTriggerContextMenu } from '@/object-record/record-table/record-table-cell/hooks/useTriggerContextMenu';
import { useUpsertRecordV2 } from '@/object-record/record-table/record-table-cell/hooks/useUpsertRecordV2';
import { RecordTableScope } from '@/object-record/record-table/scopes/RecordTableScope';
import { MoveFocusDirection } from '@/object-record/record-table/types/MoveFocusDirection';
import { TableCellPosition } from '@/object-record/record-table/types/TableCellPosition';
const StyledTable = styled.table<{
freezeFirstColumns?: boolean;
}>`
const StyledTable = styled.table`
border-radius: ${({ theme }) => theme.border.radius.sm};
border-spacing: 0;
margin-right: ${({ theme }) => theme.table.horizontalCellMargin};
table-layout: fixed;
width: calc(100% - ${({ theme }) => theme.table.horizontalCellMargin} * 2);
th {
border-block: 1px solid ${({ theme }) => theme.border.color.light};
color: ${({ theme }) => theme.font.color.tertiary};
padding: 0;
text-align: left;
:last-child {
border-right-color: transparent;
}
:first-of-type {
border-top-color: transparent;
border-bottom-color: transparent;
}
}
td {
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
color: ${({ theme }) => theme.font.color.primary};
border-right: 1px solid ${({ theme }) => theme.border.color.light};
padding: 0;
text-align: left;
:last-child {
border-right-color: transparent;
}
:first-of-type {
border-top-color: transparent;
border-bottom-color: transparent;
}
}
th {
background-color: ${({ theme }) => theme.background.primary};
border-right: 1px solid ${({ theme }) => theme.border.color.light};
}
thead th {
position: sticky;
top: 0;
z-index: 9;
}
thead th:nth-of-type(1),
thead th:nth-of-type(2),
thead th:nth-of-type(3) {
z-index: 12;
background-color: ${({ theme }) => theme.background.primary};
}
thead th:nth-of-type(1) {
width: 9px;
left: 0;
border-right-color: ${({ theme }) => theme.background.primary};
}
thead th:nth-of-type(2) {
left: 9px;
border-right-color: ${({ theme }) => theme.background.primary};
}
thead th:nth-of-type(3) {
left: 39px;
}
tbody td:nth-of-type(1),
tbody td:nth-of-type(2),
tbody td:nth-of-type(3) {
position: sticky;
z-index: 1;
}
tbody td:nth-of-type(1) {
left: 0;
z-index: 7;
}
tbody td:nth-of-type(2) {
left: 9px;
z-index: 5;
}
tbody td:nth-of-type(3) {
left: 39px;
z-index: 6;
}
thead th:nth-of-type(3),
tbody td:nth-of-type(3) {
${({ freezeFirstColumns }) =>
freezeFirstColumns &&
css`
@media (max-width: ${MOBILE_VIEWPORT}px) {
width: 35px;
max-width: 35px;
}
`}
&::after {
content: '';
height: calc(100% + 1px);
position: absolute;
width: 4px;
right: -4px;
top: 0;
${({ freezeFirstColumns, theme }) =>
freezeFirstColumns &&
css`
box-shadow: 4px 0px 4px -4px ${theme.name === 'dark'
? RGBA(theme.grayScale.gray50, 0.8)
: RGBA(theme.grayScale.gray100, 0.25)} inset;
`}
}
}
`;
type RecordTableProps = {
@ -164,97 +30,27 @@ export const RecordTable = ({
onColumnsChange,
createRecord,
}: RecordTableProps) => {
const { scopeId, visibleTableColumnsSelector } =
useRecordTableStates(recordTableId);
const { scopeId } = useRecordTableStates(recordTableId);
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { upsertRecord } = useUpsertRecordV2({
objectNameSingular,
});
const handleUpsertRecord = ({
persistField,
entityId,
fieldName,
}: {
persistField: () => void;
entityId: string;
fieldName: string;
}) => {
upsertRecord(persistField, entityId, fieldName, recordTableId);
};
const { openTableCell } = useOpenRecordTableCellV2(recordTableId);
const handleOpenTableCell = (args: OpenTableCellArgs) => {
openTableCell(args);
};
const { moveFocus } = useRecordTableMoveFocus(recordTableId);
const handleMoveFocus = (direction: MoveFocusDirection) => {
moveFocus(direction);
};
const { closeTableCell } = useCloseRecordTableCellV2(recordTableId);
const handleCloseTableCell = () => {
closeTableCell();
};
const { moveSoftFocusToCell } =
useMoveSoftFocusToCellOnHoverV2(recordTableId);
const handleMoveSoftFocusToCell = (cellPosition: TableCellPosition) => {
moveSoftFocusToCell(cellPosition);
};
const { triggerContextMenu } = useTriggerContextMenu({
recordTableId,
});
const handleContextMenu = (event: React.MouseEvent, recordId: string) => {
triggerContextMenu(event, recordId);
};
const { handleContainerMouseEnter } = useHandleContainerMouseEnter({
recordTableId,
});
const visibleTableColumns = useRecoilValue(visibleTableColumnsSelector());
if (!isNonEmptyString(objectNameSingular)) {
return <></>;
}
return (
<RecordTableScope
recordTableScopeId={scopeId}
onColumnsChange={onColumnsChange}
>
{!!objectNameSingular && (
<RecordTableContext.Provider
value={{
objectMetadataItem,
onUpsertRecord: handleUpsertRecord,
onOpenTableCell: handleOpenTableCell,
onMoveFocus: handleMoveFocus,
onCloseTableCell: handleCloseTableCell,
onMoveSoftFocusToCell: handleMoveSoftFocusToCell,
onContextMenu: handleContextMenu,
onCellMouseEnter: handleContainerMouseEnter,
visibleTableColumns,
}}
>
<StyledTable className="entity-table-cell">
<RecordTableHeader createRecord={createRecord} />
<RecordTableBodyEffect objectNameSingular={objectNameSingular} />
<RecordTableBody
objectNameSingular={objectNameSingular}
recordTableId={recordTableId}
/>
</StyledTable>
</RecordTableContext.Provider>
)}
<RecordTableContextProvider
objectNameSingular={objectNameSingular}
recordTableId={recordTableId}
>
<StyledTable className="entity-table-cell">
<RecordTableHeader createRecord={createRecord} />
<RecordTableBodyEffect />
<RecordTableBody />
</StyledTable>
</RecordTableContextProvider>
</RecordTableScope>
);
};

View File

@ -1,53 +0,0 @@
import { useRecoilValue } from 'recoil';
import { RecordTableBodyFetchMoreLoader } from '@/object-record/record-table/components/RecordTableBodyFetchMoreLoader';
import { RecordTableBodyLoading } from '@/object-record/record-table/components/RecordTableBodyLoading';
import { RecordTableRow } from '@/object-record/record-table/components/RecordTableRow';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { DraggableTableBody } from '@/ui/layout/draggable-list/components/DraggableTableBody';
type RecordTableBodyProps = {
objectNameSingular: string;
recordTableId: string;
};
export const RecordTableBody = ({
objectNameSingular,
recordTableId,
}: RecordTableBodyProps) => {
const { tableRowIdsState, isRecordTableInitialLoadingState } =
useRecordTableStates();
const tableRowIds = useRecoilValue(tableRowIdsState);
const isRecordTableInitialLoading = useRecoilValue(
isRecordTableInitialLoadingState,
);
if (isRecordTableInitialLoading && tableRowIds.length === 0) {
return <RecordTableBodyLoading />;
}
return (
<>
<DraggableTableBody
objectNameSingular={objectNameSingular}
recordTableId={recordTableId}
draggableItems={
<>
{tableRowIds.map((recordId, rowIndex) => {
return (
<RecordTableRow
key={recordId}
recordId={recordId}
rowIndex={rowIndex}
/>
);
})}
</>
}
/>
<RecordTableBodyFetchMoreLoader objectNameSingular={objectNameSingular} />
</>
);
};

View File

@ -1,61 +0,0 @@
import { useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { useDebouncedCallback } from 'use-debounce';
import { useLoadRecordIndexTable } from '@/object-record/record-index/hooks/useLoadRecordIndexTable';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { isFetchingMoreRecordsFamilyState } from '@/object-record/states/isFetchingMoreRecordsFamilyState';
import { useScrollRestoration } from '~/hooks/useScrollRestoration';
type RecordTableBodyEffectProps = {
objectNameSingular: string;
};
export const RecordTableBodyEffect = ({
objectNameSingular,
}: RecordTableBodyEffectProps) => {
const {
fetchMoreRecords: fetchMoreObjects,
records,
totalCount,
setRecordTableData,
loading,
queryStateIdentifier,
} = useLoadRecordIndexTable(objectNameSingular);
const isFetchingMoreObjects = useRecoilValue(
isFetchingMoreRecordsFamilyState(queryStateIdentifier),
);
const { tableLastRowVisibleState } = useRecordTableStates();
const tableLastRowVisible = useRecoilValue(tableLastRowVisibleState);
const rowHeight = 32;
const viewportHeight = records.length * rowHeight;
useScrollRestoration(viewportHeight);
useEffect(() => {
if (!loading) {
setRecordTableData(records, totalCount);
}
}, [records, totalCount, setRecordTableData, loading]);
const fetchMoreDebouncedIfRequested = useDebouncedCallback(async () => {
// We are debouncing here to give the user some room to scroll if they want to within this throttle window
await fetchMoreObjects();
}, 100);
useEffect(() => {
if (!isFetchingMoreObjects && tableLastRowVisible) {
fetchMoreDebouncedIfRequested();
}
}, [
fetchMoreDebouncedIfRequested,
isFetchingMoreObjects,
tableLastRowVisible,
]);
return <></>;
};

View File

@ -0,0 +1,109 @@
import { ReactNode } from 'react';
import { useRecoilValue } from 'recoil';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext';
import { useHandleContainerMouseEnter } from '@/object-record/record-table/hooks/internal/useHandleContainerMouseEnter';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { useRecordTableMoveFocus } from '@/object-record/record-table/hooks/useRecordTableMoveFocus';
import { useCloseRecordTableCellV2 } from '@/object-record/record-table/record-table-cell/hooks/useCloseRecordTableCellV2';
import { useMoveSoftFocusToCellOnHoverV2 } from '@/object-record/record-table/record-table-cell/hooks/useMoveSoftFocusToCellOnHoverV2';
import {
OpenTableCellArgs,
useOpenRecordTableCellV2,
} from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellV2';
import { useTriggerContextMenu } from '@/object-record/record-table/record-table-cell/hooks/useTriggerContextMenu';
import { useUpsertRecordV2 } from '@/object-record/record-table/record-table-cell/hooks/useUpsertRecordV2';
import { MoveFocusDirection } from '@/object-record/record-table/types/MoveFocusDirection';
import { TableCellPosition } from '@/object-record/record-table/types/TableCellPosition';
export const RecordTableContextProvider = ({
recordTableId,
objectNameSingular,
children,
}: {
recordTableId: string;
objectNameSingular: string;
children: ReactNode;
}) => {
const { visibleTableColumnsSelector } = useRecordTableStates(recordTableId);
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { upsertRecord } = useUpsertRecordV2({
objectNameSingular,
});
const handleUpsertRecord = ({
persistField,
entityId,
fieldName,
}: {
persistField: () => void;
entityId: string;
fieldName: string;
}) => {
upsertRecord(persistField, entityId, fieldName, recordTableId);
};
const { openTableCell } = useOpenRecordTableCellV2(recordTableId);
const handleOpenTableCell = (args: OpenTableCellArgs) => {
openTableCell(args);
};
const { moveFocus } = useRecordTableMoveFocus(recordTableId);
const handleMoveFocus = (direction: MoveFocusDirection) => {
moveFocus(direction);
};
const { closeTableCell } = useCloseRecordTableCellV2(recordTableId);
const handleCloseTableCell = () => {
closeTableCell();
};
const { moveSoftFocusToCell } =
useMoveSoftFocusToCellOnHoverV2(recordTableId);
const handleMoveSoftFocusToCell = (cellPosition: TableCellPosition) => {
moveSoftFocusToCell(cellPosition);
};
const { triggerContextMenu } = useTriggerContextMenu({
recordTableId,
});
const handleContextMenu = (event: React.MouseEvent, recordId: string) => {
triggerContextMenu(event, recordId);
};
const { handleContainerMouseEnter } = useHandleContainerMouseEnter({
recordTableId,
});
const visibleTableColumns = useRecoilValue(visibleTableColumnsSelector());
return (
<RecordTableContext.Provider
value={{
objectMetadataItem,
onUpsertRecord: handleUpsertRecord,
onOpenTableCell: handleOpenTableCell,
onMoveFocus: handleMoveFocus,
onCloseTableCell: handleCloseTableCell,
onMoveSoftFocusToCell: handleMoveSoftFocusToCell,
onContextMenu: handleContextMenu,
onCellMouseEnter: handleContainerMouseEnter,
visibleTableColumns,
recordTableId,
objectNameSingular,
}}
>
{children}
</RecordTableContext.Provider>
);
};

View File

@ -1,114 +0,0 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { IconPlus } from 'twenty-ui';
import { RecordTableHeaderCell } from '@/object-record/record-table/components/RecordTableHeaderCell';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useScrollWrapperScopedRef } from '@/ui/utilities/scroll/hooks/useScrollWrapperScopedRef';
import { RecordTableHeaderPlusButtonContent } from './RecordTableHeaderPlusButtonContent';
import { SelectAllCheckbox } from './SelectAllCheckbox';
const StyledTableHead = styled.thead`
cursor: pointer;
`;
const StyledPlusIconHeaderCell = styled.th<{ isTableWiderThanScreen: boolean }>`
${({ theme }) => {
return `
&:hover {
background: ${theme.background.transparent.light};
};
padding-left: ${theme.spacing(3)};
`;
}};
border-left: none !important;
min-width: 32px;
${({ isTableWiderThanScreen, theme }) =>
isTableWiderThanScreen &&
`
width: 32px;
border-right: none !important;
background-color: ${theme.background.primary};
`};
z-index: 1;
`;
const StyledPlusIconContainer = styled.div`
align-items: center;
display: flex;
height: 32px;
justify-content: center;
width: 32px;
`;
export const HIDDEN_TABLE_COLUMN_DROPDOWN_ID =
'hidden-table-columns-dropdown-scope-id';
const HIDDEN_TABLE_COLUMN_DROPDOWN_HOTKEY_SCOPE_ID =
'hidden-table-columns-dropdown-hotkey-scope-id';
export const RecordTableHeader = ({
createRecord,
}: {
createRecord: () => void;
}) => {
const { visibleTableColumnsSelector, hiddenTableColumnsSelector } =
useRecordTableStates();
const scrollWrapper = useScrollWrapperScopedRef();
const isTableWiderThanScreen =
(scrollWrapper.current?.clientWidth ?? 0) <
(scrollWrapper.current?.scrollWidth ?? 0);
const visibleTableColumns = useRecoilValue(visibleTableColumnsSelector());
const hiddenTableColumns = useRecoilValue(hiddenTableColumnsSelector());
const theme = useTheme();
return (
<StyledTableHead data-select-disable>
<tr>
<th></th>
<th
style={{
width: 30,
minWidth: 30,
maxWidth: 30,
borderRight: 'transparent',
}}
>
<SelectAllCheckbox />
</th>
{visibleTableColumns.map((column) => (
<RecordTableHeaderCell
key={column.fieldMetadataId}
column={column}
createRecord={createRecord}
/>
))}
<StyledPlusIconHeaderCell
isTableWiderThanScreen={isTableWiderThanScreen}
>
{hiddenTableColumns.length > 0 && (
<Dropdown
dropdownId={HIDDEN_TABLE_COLUMN_DROPDOWN_ID}
clickableComponent={
<StyledPlusIconContainer>
<IconPlus size={theme.icon.size.md} />
</StyledPlusIconContainer>
}
dropdownComponents={<RecordTableHeaderPlusButtonContent />}
dropdownPlacement="bottom-start"
dropdownHotkeyScope={{
scope: HIDDEN_TABLE_COLUMN_DROPDOWN_HOTKEY_SCOPE_ID,
}}
/>
)}
</StyledPlusIconHeaderCell>
</tr>
</StyledTableHead>
);
};

View File

@ -1,183 +0,0 @@
import { useContext } from 'react';
import { useInView } from 'react-intersection-observer';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Draggable } from '@hello-pangea/dnd';
import { useRecoilValue } from 'recoil';
import { getBasePathToShowPage } from '@/object-metadata/utils/getBasePathToShowPage';
import { RecordValueSetterEffect } from '@/object-record/record-store/components/RecordValueSetterEffect';
import { RecordTableCellFieldContextWrapper } from '@/object-record/record-table/components/RecordTableCellFieldContextWrapper';
import { RecordTableCellContext } from '@/object-record/record-table/contexts/RecordTableCellContext';
import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { ScrollWrapperContext } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { CheckboxCell } from './CheckboxCell';
import { GripCell } from './GripCell';
type RecordTableRowProps = {
recordId: string;
rowIndex: number;
isPendingRow?: boolean;
};
export const StyledTd = styled.td<{ isSelected?: boolean }>`
background: ${({ theme }) => theme.background.primary};
position: relative;
user-select: none;
${({ isSelected, theme }) =>
isSelected &&
`
background: ${theme.accent.quaternary};
`}
`;
export const StyledTr = styled.tr<{ isDragging: boolean }>`
border: 1px solid transparent;
transition: border-left-color 0.2s ease-in-out;
td:nth-of-type(-n + 2) {
border-right-color: ${({ theme }) => theme.background.primary};
}
${({ isDragging }) =>
isDragging &&
`
td:nth-of-type(1) {
background-color: transparent;
border-color: transparent;
}
td:nth-of-type(2) {
background-color: transparent;
border-color: transparent;
}
td:nth-of-type(3) {
background-color: transparent;
border-color: transparent;
}
`}
`;
const SelectableStyledTd = ({
isSelected,
children,
style,
}: {
isSelected: boolean;
children?: React.ReactNode;
style?: React.CSSProperties;
}) => (
<StyledTd isSelected={isSelected} style={style}>
{children}
</StyledTd>
);
export const RecordTableRow = ({
recordId,
rowIndex,
isPendingRow,
}: RecordTableRowProps) => {
const { visibleTableColumnsSelector, isRowSelectedFamilyState } =
useRecordTableStates();
const currentRowSelected = useRecoilValue(isRowSelectedFamilyState(recordId));
const { objectMetadataItem } = useContext(RecordTableContext);
const visibleTableColumns = useRecoilValue(visibleTableColumnsSelector());
const scrollWrapperRef = useContext(ScrollWrapperContext);
const { ref: elementRef, inView } = useInView({
root: scrollWrapperRef.current?.querySelector(
'[data-overlayscrollbars-viewport="scrollbarHidden"]',
),
rootMargin: '1000px',
});
const theme = useTheme();
return (
<RecordTableRowContext.Provider
value={{
recordId,
rowIndex,
pathToShowPage:
getBasePathToShowPage({
objectNameSingular: objectMetadataItem.nameSingular,
}) + recordId,
objectNameSingular: objectMetadataItem.nameSingular,
isSelected: currentRowSelected,
isReadOnly: objectMetadataItem.isRemote ?? false,
isPendingRow,
}}
>
<RecordValueSetterEffect recordId={recordId} />
<Draggable key={recordId} draggableId={recordId} index={rowIndex}>
{(draggableProvided, draggableSnapshot) => (
<StyledTr
ref={(node) => {
elementRef(node);
draggableProvided.innerRef(node);
}}
// eslint-disable-next-line react/jsx-props-no-spreading
{...draggableProvided.draggableProps}
style={{
...draggableProvided.draggableProps.style,
background: draggableSnapshot.isDragging
? theme.background.transparent.light
: 'none',
borderColor: draggableSnapshot.isDragging
? `${theme.border.color.medium}`
: 'transparent',
}}
isDragging={draggableSnapshot.isDragging}
data-testid={`row-id-${recordId}`}
data-selectable-id={recordId}
>
<StyledTd
// eslint-disable-next-line react/jsx-props-no-spreading
{...draggableProvided.dragHandleProps}
data-select-disable
>
<GripCell isDragging={draggableSnapshot.isDragging} />
</StyledTd>
<SelectableStyledTd
isSelected={currentRowSelected}
style={{ borderRight: 'transparent' }}
>
{!draggableSnapshot.isDragging && <CheckboxCell />}
</SelectableStyledTd>
{inView || draggableSnapshot.isDragging
? visibleTableColumns.map((column, columnIndex) => (
<RecordTableCellContext.Provider
value={{
columnDefinition: column,
columnIndex,
}}
key={column.fieldMetadataId}
>
{draggableSnapshot.isDragging && columnIndex > 0 ? null : (
<RecordTableCellFieldContextWrapper />
)}
</RecordTableCellContext.Provider>
))
: visibleTableColumns.map((column) => (
<StyledTd
isSelected={currentRowSelected}
key={column.fieldMetadataId}
></StyledTd>
))}
<SelectableStyledTd isSelected={currentRowSelected} />
</StyledTr>
)}
</Draggable>
</RecordTableRowContext.Provider>
);
};

View File

@ -0,0 +1,16 @@
import { useRecoilValue } from 'recoil';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { RecordTableRow } from '@/object-record/record-table/record-table-row/components/RecordTableRow';
export const RecordTableRows = () => {
const { tableRowIdsState } = useRecordTableStates();
const tableRowIds = useRecoilValue(tableRowIdsState);
return tableRowIds.map((recordId, rowIndex) => {
return (
<RecordTableRow key={recordId} recordId={recordId} rowIndex={rowIndex} />
);
});
};

View File

@ -75,6 +75,28 @@ export const RecordTableWithWrappers = ({
const isRemote = foundObjectMetadataItem?.isRemote ?? false;
const handleColumnsChange = useRecoilCallback(
() => (columns) => {
saveViewFields(
mapColumnDefinitionsToViewFields(
columns as ColumnDefinition<FieldMetadata>[],
),
);
},
[saveViewFields],
);
if (!isRecordTableInitialLoading && tableRowIds.length === 0) {
return (
<RecordTableEmptyState
objectNameSingular={objectNameSingular}
objectLabel={objectLabel}
createRecord={createRecord}
isRemote={isRemote}
/>
);
}
return (
<EntityDeleteContext.Provider value={deleteOneRecord}>
<ScrollWrapper>
@ -85,16 +107,7 @@ export const RecordTableWithWrappers = ({
<RecordTable
recordTableId={recordTableId}
objectNameSingular={objectNameSingular}
onColumnsChange={useRecoilCallback(
() => (columns) => {
saveViewFields(
mapColumnDefinitionsToViewFields(
columns as ColumnDefinition<FieldMetadata>[],
),
);
},
[saveViewFields],
)}
onColumnsChange={handleColumnsChange}
createRecord={createRecord}
/>
<DragSelect
@ -107,16 +120,6 @@ export const RecordTableWithWrappers = ({
recordTableId={recordTableId}
tableBodyRef={tableBodyRef}
/>
{!isRecordTableInitialLoading &&
// we cannot rely on count states because this is not available for remote objects
tableRowIds.length === 0 && (
<RecordTableEmptyState
objectNameSingular={objectNameSingular}
objectLabel={objectLabel}
createRecord={createRecord}
isRemote={isRemote}
/>
)}
</StyledTableContainer>
</StyledTableWithHeader>
</RecordUpdateContext.Provider>

View File

@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { useEffect } from 'react';
import { useRecoilState, useSetRecoilState } from 'recoil';
import { ComponentDecorator } from 'twenty-ui';
@ -12,7 +12,6 @@ import {
useSetRecordValue,
} from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { RecordTableCellFieldContextWrapper } from '@/object-record/record-table/components/RecordTableCellFieldContextWrapper';
import { RecordTableCellContext } from '@/object-record/record-table/contexts/RecordTableCellContext';
import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext';
@ -21,6 +20,7 @@ import { ChipGeneratorsDecorator } from '~/testing/decorators/ChipGeneratorsDeco
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory';
import { RecordTableCellFieldContextWrapper } from '@/object-record/record-table/record-table-cell/components/RecordTableCellFieldContextWrapper';
import { mockPerformance } from './mock';
const objectMetadataItems = getObjectMetadataItemsMock();
@ -73,6 +73,9 @@ const meta: Meta = {
onContextMenu: () => {},
onCellMouseEnter: () => {},
visibleTableColumns: mockPerformance.visibleTableColumns as any,
objectNameSingular:
mockPerformance.objectMetadataItem.nameSingular,
recordTableId: 'recordTableId',
}}
>
<RecordTableScope
@ -92,12 +95,19 @@ const meta: Meta = {
}) + mockPerformance.entityId,
isSelected: false,
isReadOnly: false,
isDragging: false,
dragHandleProps: null,
inView: true,
isPendingRow: false,
}}
>
<RecordTableCellContext.Provider
value={{
columnDefinition: mockPerformance.fieldDefinition,
columnIndex: 0,
cellPosition: { row: 0, column: 0 },
hasSoftFocus: false,
isInEditMode: false,
}}
>
<FieldContext.Provider

View File

@ -0,0 +1,2 @@
export const HIDDEN_TABLE_COLUMN_DROPDOWN_ID =
'hidden-table-columns-dropdown-scope-id';

View File

@ -2,12 +2,15 @@ import { createContext } from 'react';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { TableCellPosition } from '@/object-record/record-table/types/TableCellPosition';
type RecordTableRowContextProps = {
export type RecordTableCellContextProps = {
columnDefinition: ColumnDefinition<FieldMetadata>;
columnIndex: number;
isInEditMode: boolean;
hasSoftFocus: boolean;
cellPosition: TableCellPosition;
};
export const RecordTableCellContext = createContext<RecordTableRowContextProps>(
{} as RecordTableRowContextProps,
);
export const RecordTableCellContext =
createContext<RecordTableCellContextProps>({} as RecordTableCellContextProps);

View File

@ -26,6 +26,8 @@ export type RecordTableContextProps = {
onContextMenu: (event: React.MouseEvent, recordId: string) => void;
onCellMouseEnter: (args: HandleContainerMouseEnterArgs) => void;
visibleTableColumns: ColumnDefinition<FieldMetadata>[];
recordTableId: string;
objectNameSingular: string;
};
export const RecordTableContext = createContext<RecordTableContextProps>(

View File

@ -1,4 +1,5 @@
import { createContext } from 'react';
import { DraggableProvidedDragHandleProps } from '@hello-pangea/dnd';
export type RecordTableRowContextProps = {
pathToShowPage: string;
@ -8,6 +9,9 @@ export type RecordTableRowContextProps = {
isSelected: boolean;
isReadOnly: boolean;
isPendingRow?: boolean;
isDragging: boolean;
dragHandleProps: DraggableProvidedDragHandleProps | null;
inView?: boolean;
};
export const RecordTableRowContext = createContext<RecordTableRowContextProps>(

View File

@ -0,0 +1,38 @@
import { useRecoilValue } from 'recoil';
import { RecordTableRows } from '@/object-record/record-table/components/RecordTableRows';
import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { RecordTableBodyDragDropContext } from '@/object-record/record-table/record-table-body/components/RecordTableBodyDragDropContext';
import { RecordTableBodyDroppable } from '@/object-record/record-table/record-table-body/components/RecordTableBodyDroppable';
import { RecordTableBodyFetchMoreLoader } from '@/object-record/record-table/record-table-body/components/RecordTableBodyFetchMoreLoader';
import { RecordTableBodyLoading } from '@/object-record/record-table/record-table-body/components/RecordTableBodyLoading';
import { RecordTablePendingRow } from '@/object-record/record-table/record-table-row/components/RecordTablePendingRow';
import { useContext } from 'react';
export const RecordTableBody = () => {
const { tableRowIdsState, isRecordTableInitialLoadingState } =
useRecordTableStates();
const { objectNameSingular } = useContext(RecordTableContext);
const tableRowIds = useRecoilValue(tableRowIdsState);
const isRecordTableInitialLoading = useRecoilValue(
isRecordTableInitialLoadingState,
);
if (isRecordTableInitialLoading && tableRowIds.length === 0) {
return <RecordTableBodyLoading />;
}
return (
<RecordTableBodyDragDropContext>
<RecordTableBodyDroppable>
<RecordTablePendingRow />
<RecordTableRows />
</RecordTableBodyDroppable>
<RecordTableBodyFetchMoreLoader objectNameSingular={objectNameSingular} />
</RecordTableBodyDragDropContext>
);
};

View File

@ -1,42 +1,30 @@
import { useState } from 'react';
import styled from '@emotion/styled';
import { DragDropContext, Droppable, DropResult } from '@hello-pangea/dnd';
import { ReactNode, useContext } from 'react';
import { DragDropContext, DropResult } from '@hello-pangea/dnd';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { v4 } from 'uuid';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { RecordTablePendingRow } from '@/object-record/record-table/components/RecordTablePendingRow';
import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { useComputeNewRowPosition } from '@/object-record/record-table/hooks/useComputeNewRowPosition';
import { isRemoveSortingModalOpenState } from '@/object-record/record-table/states/isRemoveSortingModalOpenState';
import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
import { isDefined } from '~/utils/isDefined';
type DraggableTableBodyProps = {
draggableItems: React.ReactNode;
objectNameSingular: string;
recordTableId: string;
};
const StyledTbody = styled.tbody`
overflow: hidden;
`;
export const DraggableTableBody = ({
objectNameSingular,
draggableItems,
recordTableId,
}: DraggableTableBodyProps) => {
const [v4Persistable] = useState(v4());
const { tableRowIdsState } = useRecordTableStates();
const tableRowIds = useRecoilValue(tableRowIdsState);
export const RecordTableBodyDragDropContext = ({
children,
}: {
children: ReactNode;
}) => {
const { objectNameSingular, recordTableId } = useContext(RecordTableContext);
const { updateOneRecord: updateOneRow } = useUpdateOneRecord({
objectNameSingular,
});
const { tableRowIdsState } = useRecordTableStates();
const tableRowIds = useRecoilValue(tableRowIdsState);
const { currentViewWithCombinedFiltersAndSorts } =
useGetCurrentView(recordTableId);
@ -45,6 +33,7 @@ export const DraggableTableBody = ({
const setIsRemoveSortingModalOpenState = useSetRecoilState(
isRemoveSortingModalOpenState,
);
const computeNewRowPosition = useComputeNewRowPosition();
const handleDragEnd = (result: DropResult) => {
@ -68,20 +57,6 @@ export const DraggableTableBody = ({
};
return (
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId={v4Persistable}>
{(provided) => (
<StyledTbody
ref={provided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
>
<RecordTablePendingRow />
{draggableItems}
{provided.placeholder}
</StyledTbody>
)}
</Droppable>
</DragDropContext>
<DragDropContext onDragEnd={handleDragEnd}>{children}</DragDropContext>
);
};

View File

@ -0,0 +1,57 @@
import { Theme } from '@emotion/react';
import { Droppable } from '@hello-pangea/dnd';
import { styled } from '@linaria/react';
import { ReactNode, useContext, useState } from 'react';
import { ThemeContext } from 'twenty-ui';
import { v4 } from 'uuid';
const StyledTbody = styled.tbody<{
theme: Theme;
}>`
overflow: hidden;
&.first-columns-sticky {
td:nth-child(1) {
position: sticky;
left: 0;
z-index: 5;
}
td:nth-child(2) {
position: sticky;
left: 9px;
z-index: 5;
}
td:nth-child(3) {
position: sticky;
left: 39px;
z-index: 5;
}
}
`;
export const RecordTableBodyDroppable = ({
children,
}: {
children: ReactNode;
}) => {
const [v4Persistable] = useState(v4());
const { theme } = useContext(ThemeContext);
return (
<Droppable droppableId={v4Persistable}>
{(provided) => (
<StyledTbody
id="record-table-body"
theme={theme}
ref={provided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
>
{children}
{provided.placeholder}
</StyledTbody>
)}
</Droppable>
);
};

View File

@ -0,0 +1,106 @@
import { useContext, useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { useDebouncedCallback } from 'use-debounce';
import { useLoadRecordIndexTable } from '@/object-record/record-index/hooks/useLoadRecordIndexTable';
import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { isRecordTableScrolledLeftComponentState } from '@/object-record/record-table/states/isRecordTableScrolledLeftComponentState';
import { isRecordTableScrolledTopComponentState } from '@/object-record/record-table/states/isRecordTableScrolledTopComponentState';
import { isFetchingMoreRecordsFamilyState } from '@/object-record/states/isFetchingMoreRecordsFamilyState';
import { scrollLeftState } from '@/ui/utilities/scroll/states/scrollLeftState';
import { scrollTopState } from '@/ui/utilities/scroll/states/scrollTopState';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useScrollRestoration } from '~/hooks/useScrollRestoration';
export const RecordTableBodyEffect = () => {
const { objectNameSingular } = useContext(RecordTableContext);
const {
fetchMoreRecords: fetchMoreObjects,
records,
totalCount,
setRecordTableData,
loading,
queryStateIdentifier,
} = useLoadRecordIndexTable(objectNameSingular);
const isFetchingMoreObjects = useRecoilValue(
isFetchingMoreRecordsFamilyState(queryStateIdentifier),
);
const { tableLastRowVisibleState } = useRecordTableStates();
const tableLastRowVisible = useRecoilValue(tableLastRowVisibleState);
const scrollTop = useRecoilValue(scrollTopState);
const setIsRecordTableScrolledTop = useSetRecoilComponentState(
isRecordTableScrolledTopComponentState,
);
useEffect(() => {
setIsRecordTableScrolledTop(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, setIsRecordTableScrolledTop]);
const scrollLeft = useRecoilValue(scrollLeftState);
const setIsRecordTableScrolledLeft = useSetRecoilComponentState(
isRecordTableScrolledLeftComponentState,
);
useEffect(() => {
setIsRecordTableScrolledLeft(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');
} else {
document
.getElementById('record-table-body')
?.classList.remove('first-columns-sticky');
document
.getElementById('record-table-header')
?.classList.remove('first-columns-sticky');
}
}, [scrollLeft, setIsRecordTableScrolledLeft]);
const rowHeight = 32;
const viewportHeight = records.length * rowHeight;
useScrollRestoration(viewportHeight);
useEffect(() => {
if (!loading) {
setRecordTableData(records, totalCount);
}
}, [records, totalCount, setRecordTableData, loading]);
const fetchMoreDebouncedIfRequested = useDebouncedCallback(async () => {
// We are debouncing here to give the user some room to scroll if they want to within this throttle window
await fetchMoreObjects();
}, 100);
useEffect(() => {
if (!isFetchingMoreObjects && tableLastRowVisible) {
fetchMoreDebouncedIfRequested();
}
}, [
fetchMoreDebouncedIfRequested,
isFetchingMoreObjects,
tableLastRowVisible,
]);
return <></>;
};

View File

@ -1,13 +1,10 @@
import { useRecoilValue } from 'recoil';
import { CheckboxCell } from '@/object-record/record-table/components/CheckboxCell';
import { GripCell } from '@/object-record/record-table/components/GripCell';
import {
StyledTd,
StyledTr,
} from '@/object-record/record-table/components/RecordTableRow';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
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 { RecordTableCellLoading } from '@/object-record/record-table/record-table-cell/components/RecordTableCellLoading';
import { RecordTableTr } from '@/object-record/record-table/record-table-row/components/RecordTableTr';
export const RecordTableBodyLoading = () => {
const { visibleTableColumnsSelector } = useRecordTableStates();
@ -16,22 +13,18 @@ export const RecordTableBodyLoading = () => {
return (
<tbody>
{Array.from({ length: 8 }).map((_, rowIndex) => (
<StyledTr
<RecordTableTr
isDragging={false}
data-testid={`row-id-${rowIndex}`}
data-selectable-id={`row-id-${rowIndex}`}
key={rowIndex}
>
<StyledTd data-select-disable>
<GripCell isDragging={false} />
</StyledTd>
<StyledTd>
<CheckboxCell />
</StyledTd>
<RecordTableCellGrip />
<RecordTableCellCheckbox />
{visibleTableColumns.map((column) => (
<RecordTableCellLoading key={column.fieldMetadataId} />
))}
</StyledTr>
</RecordTableTr>
))}
</tbody>
);

View File

@ -1,109 +1,13 @@
import { useContext } from 'react';
import { FieldDisplay } from '@/object-record/record-field/components/FieldDisplay';
import { FieldInput } from '@/object-record/record-field/components/FieldInput';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { FieldFocusContextProvider } from '@/object-record/record-field/contexts/FieldFocusContextProvider';
import { FieldInputEvent } from '@/object-record/record-field/types/FieldInputEvent';
import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext';
import { RecordTableCellContainer } from '@/object-record/record-table/record-table-cell/components/RecordTableCellContainer';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
export const RecordTableCell = ({
customHotkeyScope,
}: {
customHotkeyScope: HotkeyScope;
}) => {
const { onUpsertRecord, onMoveFocus, onCloseTableCell } =
useContext(RecordTableContext);
const { entityId, fieldDefinition } = useContext(FieldContext);
const { isReadOnly } = useContext(RecordTableRowContext);
const handleEnter: FieldInputEvent = (persistField) => {
onUpsertRecord({
persistField,
entityId,
fieldName: fieldDefinition.metadata.fieldName,
});
onCloseTableCell();
onMoveFocus('down');
};
const handleSubmit: FieldInputEvent = (persistField) => {
onUpsertRecord({
persistField,
entityId,
fieldName: fieldDefinition.metadata.fieldName,
});
onCloseTableCell();
};
const handleCancel = () => {
onCloseTableCell();
};
const handleClickOutside: FieldInputEvent = (persistField) => {
onUpsertRecord({
persistField,
entityId,
fieldName: fieldDefinition.metadata.fieldName,
});
onCloseTableCell();
};
const handleEscape: FieldInputEvent = (persistField) => {
onUpsertRecord({
persistField,
entityId,
fieldName: fieldDefinition.metadata.fieldName,
});
onCloseTableCell();
};
const handleTab: FieldInputEvent = (persistField) => {
onUpsertRecord({
persistField,
entityId,
fieldName: fieldDefinition.metadata.fieldName,
});
onCloseTableCell();
onMoveFocus('right');
};
const handleShiftTab: FieldInputEvent = (persistField) => {
onUpsertRecord({
persistField,
entityId,
fieldName: fieldDefinition.metadata.fieldName,
});
onCloseTableCell();
onMoveFocus('left');
};
import { RecordTableCellFieldInput } from '@/object-record/record-table/record-table-cell/components/RecordTableCellFieldInput';
export const RecordTableCell = () => {
return (
<FieldFocusContextProvider>
<RecordTableCellContainer
editHotkeyScope={customHotkeyScope}
editModeContent={
<FieldInput
recordFieldInputdId={`${entityId}-${fieldDefinition?.metadata?.fieldName}`}
onCancel={handleCancel}
onClickOutside={handleClickOutside}
onEnter={handleEnter}
onEscape={handleEscape}
onShiftTab={handleShiftTab}
onSubmit={handleSubmit}
onTab={handleTab}
isReadOnly={isReadOnly}
/>
}
editModeContent={<RecordTableCellFieldInput />}
nonEditModeContent={<FieldDisplay />}
/>
</FieldFocusContextProvider>

View File

@ -0,0 +1,101 @@
import { ReactNode, useContext } from 'react';
import { styled } from '@linaria/react';
import { BORDER_COMMON, ThemeContext } from 'twenty-ui';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus';
import { CellHotkeyScopeContext } from '@/object-record/record-table/contexts/CellHotkeyScopeContext';
import { RecordTableCellContext } from '@/object-record/record-table/contexts/RecordTableCellContext';
import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext';
import {
DEFAULT_CELL_SCOPE,
useOpenRecordTableCellFromCell,
} from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellFromCell';
const StyledBaseContainer = styled.div<{
hasSoftFocus: boolean;
fontColorExtraLight: string;
backgroundColorTransparentSecondary: string;
}>`
align-items: center;
box-sizing: border-box;
cursor: pointer;
display: flex;
height: 32px;
position: relative;
user-select: none;
background: ${({ hasSoftFocus, backgroundColorTransparentSecondary }) =>
hasSoftFocus ? backgroundColorTransparentSecondary : 'none'};
border-radius: ${({ hasSoftFocus }) =>
hasSoftFocus ? BORDER_COMMON.radius.sm : 'none'};
outline: ${({ hasSoftFocus, fontColorExtraLight }) =>
hasSoftFocus ? `1px solid ${fontColorExtraLight}` : 'none'};
`;
export const RecordTableCellBaseContainer = ({
children,
}: {
children: ReactNode;
}) => {
const { setIsFocused } = useFieldFocus();
const { openTableCell } = useOpenRecordTableCellFromCell();
const { theme } = useContext(ThemeContext);
const { recordId } = useContext(RecordTableRowContext);
const { hasSoftFocus, cellPosition } = useContext(RecordTableCellContext);
const { onMoveSoftFocusToCell, onCellMouseEnter } =
useContext(RecordTableContext);
const handleContainerMouseMove = () => {
setIsFocused(true);
if (!hasSoftFocus) {
onCellMouseEnter({
cellPosition,
});
}
};
const handleContainerMouseLeave = () => {
setIsFocused(false);
};
const handleContainerClick = () => {
if (!hasSoftFocus) {
onMoveSoftFocusToCell(cellPosition);
openTableCell();
}
};
const { onContextMenu } = useContext(RecordTableContext);
const handleContextMenu = (event: React.MouseEvent) => {
onContextMenu(event, recordId);
};
const { hotkeyScope } = useContext(FieldContext);
const editHotkeyScope = { scope: hotkeyScope } ?? DEFAULT_CELL_SCOPE;
return (
<CellHotkeyScopeContext.Provider value={editHotkeyScope}>
<StyledBaseContainer
onMouseLeave={handleContainerMouseLeave}
onMouseMove={handleContainerMouseMove}
onClick={handleContainerClick}
onContextMenu={handleContextMenu}
backgroundColorTransparentSecondary={
theme.background.transparent.secondary
}
fontColorExtraLight={theme.font.color.extraLight}
hasSoftFocus={hasSoftFocus}
>
{children}
</StyledBaseContainer>
</CellHotkeyScopeContext.Provider>
);
};

View File

@ -1,8 +1,8 @@
import styled from '@emotion/styled';
import { IconComponent } from 'twenty-ui';
import { AnimatedContainer } from '@/object-record/record-table/components/AnimatedContainer';
import { FloatingIconButton } from '@/ui/input/button/components/FloatingIconButton';
import { AnimatedContainer } from '@/ui/utilities/animation/components/AnimatedContainer';
const StyledButtonContainer = styled.div`
margin: ${({ theme }) => theme.spacing(1)};

View File

@ -1,9 +1,10 @@
import { useCallback, useContext } from 'react';
import styled from '@emotion/styled';
import { useCallback, useContext } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { RecordTableTd } from '@/object-record/record-table/record-table-cell/components/RecordTableTd';
import { useSetCurrentRowSelected } from '@/object-record/record-table/record-table-row/hooks/useSetCurrentRowSelected';
import { Checkbox } from '@/ui/input/components/Checkbox';
import { actionBarOpenState } from '@/ui/navigation/action-bar/states/actionBarIsOpenState';
@ -18,7 +19,9 @@ const StyledContainer = styled.div`
justify-content: center;
`;
export const CheckboxCell = () => {
export const RecordTableCellCheckbox = () => {
const { isSelected } = useContext(RecordTableRowContext);
const { recordId } = useContext(RecordTableRowContext);
const { isRowSelectedFamilyState } = useRecordTableStates();
const setActionBarOpenState = useSetRecoilState(actionBarOpenState);
@ -31,8 +34,10 @@ export const CheckboxCell = () => {
}, [currentRowSelected, setActionBarOpenState, setCurrentRowSelected]);
return (
<StyledContainer onClick={handleClick}>
<Checkbox checked={currentRowSelected} />
</StyledContainer>
<RecordTableTd isSelected={isSelected} hasRightBorder={false}>
<StyledContainer onClick={handleClick}>
<Checkbox checked={currentRowSelected} />
</StyledContainer>
</RecordTableTd>
);
};

View File

@ -1,176 +1,41 @@
import React, { ReactElement, useContext } from 'react';
import { styled } from '@linaria/react';
import { useRecoilValue } from 'recoil';
import { BORDER_COMMON, ThemeContext } from 'twenty-ui';
import { ReactElement, useContext } from 'react';
import { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus';
import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext';
import { RecordTableCellContext } from '@/object-record/record-table/contexts/RecordTableCellContext';
import { RecordTableCellBaseContainer } from '@/object-record/record-table/record-table-cell/components/RecordTableCellBaseContainer';
import { RecordTableCellSoftFocusMode } from '@/object-record/record-table/record-table-cell/components/RecordTableCellSoftFocusMode';
import { useCurrentTableCellPosition } from '@/object-record/record-table/record-table-cell/hooks/useCurrentCellPosition';
import { useOpenRecordTableCellFromCell } from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellFromCell';
import { RecordTableScopeInternalContext } from '@/object-record/record-table/scopes/scope-internal-context/RecordTableScopeInternalContext';
import { isSoftFocusOnTableCellComponentFamilyState } from '@/object-record/record-table/states/isSoftFocusOnTableCellComponentFamilyState';
import { isTableCellInEditModeComponentFamilyState } from '@/object-record/record-table/states/isTableCellInEditModeComponentFamilyState';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
import { getScopeIdOrUndefinedFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdOrUndefinedFromComponentId';
import { extractComponentFamilyState } from '@/ui/utilities/state/component-state/utils/extractComponentFamilyState';
import { CellHotkeyScopeContext } from '../../contexts/CellHotkeyScopeContext';
import { TableHotkeyScope } from '../../types/TableHotkeyScope';
import { RecordTableCellDisplayMode } from './RecordTableCellDisplayMode';
import { RecordTableCellEditMode } from './RecordTableCellEditMode';
const StyledTd = styled.td<{
isInEditMode: boolean;
backgroundColor: string;
}>`
background: ${({ backgroundColor }) => backgroundColor};
z-index: ${({ isInEditMode }) => (isInEditMode ? '4 !important' : 3)};
`;
const borderRadiusSm = BORDER_COMMON.radius.sm;
const StyledBaseContainer = styled.div<{
hasSoftFocus: boolean;
fontColorExtraLight: string;
backgroundColorTransparentSecondary: string;
}>`
align-items: center;
box-sizing: border-box;
cursor: pointer;
display: flex;
height: 32px;
position: relative;
user-select: none;
background: ${({ hasSoftFocus, backgroundColorTransparentSecondary }) =>
hasSoftFocus ? backgroundColorTransparentSecondary : 'none'};
border-radius: ${({ hasSoftFocus }) =>
hasSoftFocus ? borderRadiusSm : 'none'};
border: ${({ hasSoftFocus, fontColorExtraLight }) =>
hasSoftFocus ? `1px solid ${fontColorExtraLight}` : 'none'};
`;
export type RecordTableCellContainerProps = {
editModeContent: ReactElement;
nonEditModeContent: ReactElement;
editHotkeyScope?: HotkeyScope;
transparent?: boolean;
maxContentWidth?: number;
onSubmit?: () => void;
onCancel?: () => void;
};
const DEFAULT_CELL_SCOPE: HotkeyScope = {
scope: TableHotkeyScope.CellEditMode,
};
export const RecordTableCellContainer = ({
editModeContent,
nonEditModeContent,
editHotkeyScope,
}: RecordTableCellContainerProps) => {
const { theme } = useContext(ThemeContext);
const { setIsFocused } = useFieldFocus();
const { openTableCell } = useOpenRecordTableCellFromCell();
const { isSelected, recordId } = useContext(RecordTableRowContext);
const { onMoveSoftFocusToCell, onContextMenu, onCellMouseEnter } =
useContext(RecordTableContext);
const tableScopeId = useAvailableScopeIdOrThrow(
RecordTableScopeInternalContext,
getScopeIdOrUndefinedFromComponentId(),
);
const isTableCellInEditModeFamilyState = extractComponentFamilyState(
isTableCellInEditModeComponentFamilyState,
tableScopeId,
);
const isSoftFocusOnTableCellFamilyState = extractComponentFamilyState(
isSoftFocusOnTableCellComponentFamilyState,
tableScopeId,
);
const cellPosition = useCurrentTableCellPosition();
const isInEditMode = useRecoilValue(
isTableCellInEditModeFamilyState(cellPosition),
);
const hasSoftFocus = useRecoilValue(
isSoftFocusOnTableCellFamilyState(cellPosition),
);
const handleContextMenu = (event: React.MouseEvent) => {
onContextMenu(event, recordId);
};
const handleContainerMouseMove = () => {
setIsFocused(true);
if (!hasSoftFocus) {
onCellMouseEnter({
cellPosition,
});
}
};
const handleContainerMouseLeave = () => {
setIsFocused(false);
};
const handleContainerClick = () => {
if (!hasSoftFocus) {
onMoveSoftFocusToCell(cellPosition);
openTableCell();
}
};
const tdBackgroundColor = isSelected
? theme.accent.quaternary
: theme.background.primary;
const { hasSoftFocus, isInEditMode } = useContext(RecordTableCellContext);
return (
<StyledTd
backgroundColor={tdBackgroundColor}
isInEditMode={isInEditMode}
onContextMenu={handleContextMenu}
>
<CellHotkeyScopeContext.Provider
value={editHotkeyScope ?? DEFAULT_CELL_SCOPE}
>
<StyledBaseContainer
onMouseLeave={handleContainerMouseLeave}
onMouseMove={handleContainerMouseMove}
onClick={handleContainerClick}
backgroundColorTransparentSecondary={
theme.background.transparent.secondary
}
fontColorExtraLight={theme.font.color.extraLight}
hasSoftFocus={hasSoftFocus}
>
{isInEditMode ? (
<RecordTableCellEditMode>{editModeContent}</RecordTableCellEditMode>
) : hasSoftFocus ? (
<RecordTableCellSoftFocusMode
editModeContent={editModeContent}
nonEditModeContent={nonEditModeContent}
/>
) : (
<RecordTableCellDisplayMode>
{nonEditModeContent}
</RecordTableCellDisplayMode>
)}
</StyledBaseContainer>
</CellHotkeyScopeContext.Provider>
</StyledTd>
<RecordTableCellBaseContainer>
{isInEditMode ? (
<RecordTableCellEditMode>{editModeContent}</RecordTableCellEditMode>
) : hasSoftFocus ? (
<RecordTableCellSoftFocusMode
editModeContent={editModeContent}
nonEditModeContent={nonEditModeContent}
/>
) : (
<RecordTableCellDisplayMode>
{nonEditModeContent}
</RecordTableCellDisplayMode>
)}
</RecordTableCellBaseContainer>
);
};

View File

@ -10,8 +10,6 @@ const StyledOuterContainer = styled.div<{
overflow: hidden;
padding-left: 6px;
width: 100%;
margin: ${({ hasSoftFocus }) => (hasSoftFocus === true ? '-1px' : 'none')};
`;
const StyledInnerContainer = styled.div`

View File

@ -1,4 +1,4 @@
import { useContext } from 'react';
import { ReactNode, useContext } from 'react';
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
@ -8,13 +8,16 @@ import { RecordUpdateContext } from '@/object-record/record-table/contexts/Entit
import { RecordTableCellContext } from '@/object-record/record-table/contexts/RecordTableCellContext';
import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext';
import { RecordTableCell } from '@/object-record/record-table/record-table-cell/components/RecordTableCell';
import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope';
import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope';
import { SelectFieldHotkeyScope } from '@/object-record/select/types/SelectFieldHotkeyScope';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export const RecordTableCellFieldContextWrapper = () => {
export const RecordTableCellFieldContextWrapper = ({
children,
}: {
children: ReactNode;
}) => {
const { objectMetadataItem } = useContext(RecordTableContext);
const { columnDefinition } = useContext(RecordTableCellContext);
const { recordId, pathToShowPage } = useContext(RecordTableRowContext);
@ -49,7 +52,7 @@ export const RecordTableCellFieldContextWrapper = () => {
}),
}}
>
<RecordTableCell customHotkeyScope={{ scope: customHotkeyScope }} />
{children}
</FieldContext.Provider>
);
};

View File

@ -0,0 +1,95 @@
import { useContext } from 'react';
import { FieldInput } from '@/object-record/record-field/components/FieldInput';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { FieldInputEvent } from '@/object-record/record-field/types/FieldInputEvent';
import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext';
export const RecordTableCellFieldInput = () => {
const { onUpsertRecord, onMoveFocus, onCloseTableCell } =
useContext(RecordTableContext);
const { entityId, fieldDefinition } = useContext(FieldContext);
const { isReadOnly } = useContext(RecordTableRowContext);
const handleEnter: FieldInputEvent = (persistField) => {
onUpsertRecord({
persistField,
entityId,
fieldName: fieldDefinition.metadata.fieldName,
});
onCloseTableCell();
onMoveFocus('down');
};
const handleSubmit: FieldInputEvent = (persistField) => {
onUpsertRecord({
persistField,
entityId,
fieldName: fieldDefinition.metadata.fieldName,
});
onCloseTableCell();
};
const handleCancel = () => {
onCloseTableCell();
};
const handleClickOutside: FieldInputEvent = (persistField) => {
onUpsertRecord({
persistField,
entityId,
fieldName: fieldDefinition.metadata.fieldName,
});
onCloseTableCell();
};
const handleEscape: FieldInputEvent = (persistField) => {
onUpsertRecord({
persistField,
entityId,
fieldName: fieldDefinition.metadata.fieldName,
});
onCloseTableCell();
};
const handleTab: FieldInputEvent = (persistField) => {
onUpsertRecord({
persistField,
entityId,
fieldName: fieldDefinition.metadata.fieldName,
});
onCloseTableCell();
onMoveFocus('right');
};
const handleShiftTab: FieldInputEvent = (persistField) => {
onUpsertRecord({
persistField,
entityId,
fieldName: fieldDefinition.metadata.fieldName,
});
onCloseTableCell();
onMoveFocus('left');
};
return (
<FieldInput
recordFieldInputdId={`${entityId}-${fieldDefinition?.metadata?.fieldName}`}
onCancel={handleCancel}
onClickOutside={handleClickOutside}
onEnter={handleEnter}
onEscape={handleEscape}
onShiftTab={handleShiftTab}
onSubmit={handleSubmit}
onTab={handleTab}
isReadOnly={isReadOnly}
/>
);
};

View File

@ -0,0 +1,44 @@
import styled from '@emotion/styled';
import { useContext } from 'react';
import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext';
import { RecordTableTd } from '@/object-record/record-table/record-table-cell/components/RecordTableTd';
import { IconListViewGrip } from '@/ui/input/components/IconListViewGrip';
const StyledContainer = styled.div`
cursor: grab;
width: 16px;
height: 32px;
z-index: 200;
display: flex;
&:hover .icon {
opacity: 1;
}
border-color: transparent;
`;
const StyledIconWrapper = styled.div<{ isDragging: boolean }>`
opacity: ${({ isDragging }) => (isDragging ? 1 : 0)};
transition: opacity 0.1s;
`;
export const RecordTableCellGrip = () => {
const { dragHandleProps, isDragging } = useContext(RecordTableRowContext);
return (
<RecordTableTd
// eslint-disable-next-line react/jsx-props-no-spreading
{...dragHandleProps}
data-select-disable
hasRightBorder={false}
hasBottomBorder={false}
>
<StyledContainer>
<StyledIconWrapper className="icon" isDragging={isDragging}>
<IconListViewGrip />
</StyledIconWrapper>
</StyledContainer>
</RecordTableTd>
);
};

View File

@ -1,10 +1,10 @@
import { StyledTd } from '@/object-record/record-table/components/RecordTableRow';
import { RecordTableCellSkeletonLoader } from '@/object-record/record-table/record-table-cell/components/RecordTableCellSkeletonLoader';
import { RecordTableTd } from '@/object-record/record-table/record-table-cell/components/RecordTableTd';
export const RecordTableCellLoading = () => {
return (
<StyledTd>
<RecordTableTd>
<RecordTableCellSkeletonLoader />
</StyledTd>
</RecordTableTd>
);
};

View File

@ -0,0 +1,75 @@
import { useContext, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { RecordTableCellContext } from '@/object-record/record-table/contexts/RecordTableCellContext';
import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext';
import { RecordTableCellFieldContextWrapper } from '@/object-record/record-table/record-table-cell/components/RecordTableCellFieldContextWrapper';
import { RecordTableScopeInternalContext } from '@/object-record/record-table/scopes/scope-internal-context/RecordTableScopeInternalContext';
import { isSoftFocusOnTableCellComponentFamilyState } from '@/object-record/record-table/states/isSoftFocusOnTableCellComponentFamilyState';
import { isTableCellInEditModeComponentFamilyState } from '@/object-record/record-table/states/isTableCellInEditModeComponentFamilyState';
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { TableCellPosition } from '@/object-record/record-table/types/TableCellPosition';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
import { getScopeIdOrUndefinedFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdOrUndefinedFromComponentId';
import { extractComponentFamilyState } from '@/ui/utilities/state/component-state/utils/extractComponentFamilyState';
export const RecordTableCellWrapper = ({
children,
column,
columnIndex,
}: {
column: ColumnDefinition<FieldMetadata>;
columnIndex: number;
children: React.ReactNode;
}) => {
const tableScopeId = useAvailableScopeIdOrThrow(
RecordTableScopeInternalContext,
getScopeIdOrUndefinedFromComponentId(),
);
const { rowIndex } = useContext(RecordTableRowContext);
const currentTableCellPosition: TableCellPosition = useMemo(
() => ({
column: columnIndex,
row: rowIndex,
}),
[columnIndex, rowIndex],
);
const isTableCellInEditModeFamilyState = extractComponentFamilyState(
isTableCellInEditModeComponentFamilyState,
tableScopeId,
);
const isSoftFocusOnTableCellFamilyState = extractComponentFamilyState(
isSoftFocusOnTableCellComponentFamilyState,
tableScopeId,
);
const isInEditMode = useRecoilValue(
isTableCellInEditModeFamilyState(currentTableCellPosition),
);
const hasSoftFocus = useRecoilValue(
isSoftFocusOnTableCellFamilyState(currentTableCellPosition),
);
return (
<RecordTableCellContext.Provider
value={{
columnDefinition: column,
columnIndex,
isInEditMode,
hasSoftFocus,
cellPosition: currentTableCellPosition,
}}
key={column.fieldMetadataId}
>
<RecordTableCellFieldContextWrapper>
{children}
</RecordTableCellFieldContextWrapper>
</RecordTableCellContext.Provider>
);
};

View File

@ -0,0 +1,10 @@
import { useContext } from 'react';
import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext';
import { RecordTableTd } from '@/object-record/record-table/record-table-cell/components/RecordTableTd';
export const RecordTableLastEmptyCell = () => {
const { isSelected } = useContext(RecordTableRowContext);
return <RecordTableTd isSelected={isSelected} hasRightBorder={false} />;
};

View File

@ -0,0 +1,102 @@
import { DraggableProvidedDragHandleProps } from '@hello-pangea/dnd';
import { styled } from '@linaria/react';
import { ReactNode, useContext } from 'react';
import { MOBILE_VIEWPORT, ThemeContext } from 'twenty-ui';
import { isDefined } from '~/utils/isDefined';
const StyledTd = styled.td<{
zIndex?: number;
backgroundColor: string;
borderColor: string;
isDragging?: boolean;
fontColor: string;
sticky?: boolean;
freezeFirstColumns?: boolean;
left?: number;
hasRightBorder?: boolean;
hasBottomBorder?: boolean;
}>`
border-bottom: 1px solid
${({ borderColor, hasBottomBorder }) =>
hasBottomBorder ? borderColor : 'transparent'};
color: ${({ fontColor }) => fontColor};
border-right: 1px solid
${({ borderColor, hasRightBorder }) =>
hasRightBorder ? borderColor : 'transparent'};
padding: 0;
text-align: left;
background: ${({ backgroundColor }) => backgroundColor};
z-index: ${({ zIndex }) => (isDefined(zIndex) ? zIndex : 'auto')};
${({ isDragging }) =>
isDragging
? `
background-color: transparent;
border-color: transparent;
`
: ''}
${({ freezeFirstColumns }) =>
freezeFirstColumns
? `@media (max-width: ${MOBILE_VIEWPORT}px) {
width: 35px;
max-width: 35px;
}`
: ''}
`;
export const RecordTableTd = ({
children,
zIndex,
isSelected,
isDragging,
sticky,
freezeFirstColumns,
left,
hasRightBorder = true,
hasBottomBorder = true,
...dragHandleProps
}: {
className?: string;
children?: ReactNode;
zIndex?: number;
isSelected?: boolean;
isDragging?: boolean;
sticky?: boolean;
freezeFirstColumns?: boolean;
hasRightBorder?: boolean;
hasBottomBorder?: boolean;
left?: number;
} & (Partial<DraggableProvidedDragHandleProps> | null)) => {
const { theme } = useContext(ThemeContext);
const tdBackgroundColor = isSelected
? theme.accent.quaternary
: theme.background.primary;
const borderColor = theme.border.color.light;
const fontColor = theme.font.color.primary;
return (
<StyledTd
isDragging={isDragging}
zIndex={zIndex}
backgroundColor={tdBackgroundColor}
borderColor={borderColor}
fontColor={fontColor}
sticky={sticky}
freezeFirstColumns={freezeFirstColumns}
left={left}
hasRightBorder={hasRightBorder}
hasBottomBorder={hasBottomBorder}
// eslint-disable-next-line react/jsx-props-no-spreading
{...dragHandleProps}
>
{children}
</StyledTd>
);
};

View File

@ -1,6 +1,5 @@
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { RecordTableCellContextProps } from '@/object-record/record-table/contexts/RecordTableCellContext';
import { RecordTableRowContextProps } from '@/object-record/record-table/contexts/RecordTableRowContext';
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const recordTableRow: RecordTableRowContextProps = {
@ -10,12 +9,13 @@ export const recordTableRow: RecordTableRowContextProps = {
pathToShowPage: '/',
objectNameSingular: 'objectNameSingular',
isReadOnly: false,
dragHandleProps: {} as any,
isDragging: false,
inView: true,
isPendingRow: false,
};
export const recordTableCell: {
columnDefinition: ColumnDefinition<FieldMetadata>;
columnIndex: number;
} = {
export const recordTableCell:RecordTableCellContextProps= {
columnIndex: 3,
columnDefinition: {
size: 1,
@ -29,4 +29,10 @@ export const recordTableCell: {
fieldName: 'fieldName',
},
},
cellPosition: {
row: 2,
column: 3,
},
hasSoftFocus: false,
isInEditMode: false,
};

View File

@ -1,21 +1,9 @@
import { useContext, useMemo } from 'react';
import { useContext } from 'react';
import { RecordTableCellContext } from '@/object-record/record-table/contexts/RecordTableCellContext';
import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext';
import { TableCellPosition } from '../../types/TableCellPosition';
export const useCurrentTableCellPosition = () => {
const { rowIndex } = useContext(RecordTableRowContext);
const { columnIndex } = useContext(RecordTableCellContext);
const { cellPosition } = useContext(RecordTableCellContext);
const currentTableCellPosition: TableCellPosition = useMemo(
() => ({
column: columnIndex,
row: rowIndex,
}),
[columnIndex, rowIndex],
);
return currentTableCellPosition;
return cellPosition;
};

View File

@ -1,14 +1,14 @@
import { css, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { MOBILE_VIEWPORT, useIcons } from 'twenty-ui';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { scrollLeftState } from '@/ui/utilities/scroll/states/scrollLeftState';
import { isRecordTableScrolledLeftComponentState } from '@/object-record/record-table/states/isRecordTableScrolledLeftComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { ColumnDefinition } from '../types/ColumnDefinition';
import { ColumnDefinition } from '../../types/ColumnDefinition';
type ColumnHeadProps = {
type RecordTableColumnHeadProps = {
column: ColumnDefinition<FieldMetadata>;
};
@ -46,16 +46,22 @@ const StyledText = styled.span`
white-space: nowrap;
`;
export const ColumnHead = ({ column }: ColumnHeadProps) => {
export const RecordTableColumnHead = ({
column,
}: RecordTableColumnHeadProps) => {
const theme = useTheme();
const { getIcon } = useIcons();
const Icon = getIcon(column.iconName);
const scrollLeft = useRecoilValue(scrollLeftState);
const isRecordTableScrolledLeft = useRecoilComponentValue(
isRecordTableScrolledLeftComponentState,
);
return (
<StyledTitle hideTitle={!!column.isLabelIdentifier && scrollLeft > 0}>
<StyledTitle
hideTitle={!!column.isLabelIdentifier && !isRecordTableScrolledLeft}
>
<StyledIcon>
<Icon size={theme.icon.size.md} />
</StyledIcon>

View File

@ -14,16 +14,16 @@ import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownM
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { useTableColumns } from '../hooks/useTableColumns';
import { ColumnDefinition } from '../types/ColumnDefinition';
import { useTableColumns } from '../../hooks/useTableColumns';
import { ColumnDefinition } from '../../types/ColumnDefinition';
export type RecordTableColumnDropdownMenuProps = {
export type RecordTableColumnHeadDropdownMenuProps = {
column: ColumnDefinition<FieldMetadata>;
};
export const RecordTableColumnDropdownMenu = ({
export const RecordTableColumnHeadDropdownMenu = ({
column,
}: RecordTableColumnDropdownMenuProps) => {
}: RecordTableColumnHeadDropdownMenuProps) => {
const {
visibleTableColumnsSelector,
onToggleColumnFilterState,

View File

@ -4,26 +4,29 @@ import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { ColumnHead } from './ColumnHead';
import { RecordTableColumnDropdownMenu } from './RecordTableColumnDropdownMenu';
import { RecordTableColumnHeadDropdownMenu } from './RecordTableColumnHeadDropdownMenu';
type ColumnHeadWithDropdownProps = {
import { RecordTableColumnHead } from './RecordTableColumnHead';
type RecordTableColumnHeadWithDropdownProps = {
column: ColumnDefinition<FieldMetadata>;
};
const StyledDropdown = styled(Dropdown)`
display: flex;
flex: 1;
z-index: ${({ theme }) => theme.lastLayerZIndex};
`;
export const ColumnHeadWithDropdown = ({
export const RecordTableColumnHeadWithDropdown = ({
column,
}: ColumnHeadWithDropdownProps) => {
}: RecordTableColumnHeadWithDropdownProps) => {
return (
<StyledDropdown
dropdownId={column.fieldMetadataId + '-header'}
clickableComponent={<ColumnHead column={column} />}
dropdownComponents={<RecordTableColumnDropdownMenu column={column} />}
clickableComponent={<RecordTableColumnHead column={column} />}
dropdownComponents={<RecordTableColumnHeadDropdownMenu column={column} />}
dropdownOffset={{ x: -1 }}
dropdownPlacement="bottom-start"
dropdownHotkeyScope={{ scope: column.fieldMetadataId + '-header' }}

View File

@ -0,0 +1,91 @@
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { MOBILE_VIEWPORT } from 'twenty-ui';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
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';
const StyledTableHead = styled.thead<{
isScrolledTop?: boolean;
isScrolledLeft?: boolean;
}>`
cursor: pointer;
th:nth-of-type(1) {
width: 9px;
left: 0;
border-right-color: ${({ theme }) => theme.background.primary};
}
th:nth-of-type(2) {
border-right-color: ${({ theme }) => theme.background.primary};
}
&.first-columns-sticky {
th:nth-child(1) {
position: sticky;
left: 0;
z-index: 5;
}
th:nth-child(2) {
position: sticky;
left: 9px;
z-index: 5;
}
th:nth-child(3) {
position: sticky;
left: 39px;
z-index: 5;
@media (max-width: ${MOBILE_VIEWPORT}px) {
width: 35px;
max-width: 35px;
}
}
}
&.header-sticky {
th {
position: sticky;
top: 0;
z-index: 5;
}
}
&.header-sticky.first-columns-sticky {
th:nth-child(1),
th:nth-child(2),
th:nth-child(3) {
z-index: 10;
}
}
`;
export const RecordTableHeader = ({
createRecord,
}: {
createRecord: () => void;
}) => {
const { visibleTableColumnsSelector } = useRecordTableStates();
const visibleTableColumns = useRecoilValue(visibleTableColumnsSelector());
return (
<StyledTableHead id="record-table-header" data-select-disable>
<tr>
<RecordTableHeaderDragDropColumn />
<RecordTableHeaderCheckboxColumn />
{visibleTableColumns.map((column) => (
<RecordTableHeaderCell
key={column.fieldMetadataId}
column={column}
createRecord={createRecord}
/>
))}
<RecordTableHeaderLastColumn />
</tr>
</StyledTableHead>
);
};

View File

@ -1,27 +1,35 @@
import { useCallback, useMemo, useState } from 'react';
import styled from '@emotion/styled';
import { useCallback, useMemo, useState } from 'react';
import { useRecoilCallback, useRecoilState, useRecoilValue } from 'recoil';
import { IconPlus } from 'twenty-ui';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { useTableColumns } from '@/object-record/record-table/hooks/useTableColumns';
import { RecordTableColumnHeadWithDropdown } from '@/object-record/record-table/record-table-header/components/RecordTableColumnHeadWithDropdown';
import { isRecordTableScrolledLeftComponentState } from '@/object-record/record-table/states/isRecordTableScrolledLeftComponentState';
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { useTrackPointer } from '@/ui/utilities/pointer-event/hooks/useTrackPointer';
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { scrollLeftState } from '@/ui/utilities/scroll/states/scrollLeftState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { mapArrayToObject } from '~/utils/array/mapArrayToObject';
import { ColumnHeadWithDropdown } from './ColumnHeadWithDropdown';
const COLUMN_MIN_WIDTH = 104;
const StyledColumnHeaderCell = styled.th<{
columnWidth: number;
isResizing?: boolean;
}>`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
border-top: 1px solid ${({ theme }) => theme.border.color.light};
color: ${({ theme }) => theme.font.color.tertiary};
padding: 0;
text-align: left;
background-color: ${({ theme }) => theme.background.primary};
border-right: 1px solid ${({ theme }) => theme.border.color.light};
${({ columnWidth }) => `
min-width: ${columnWidth}px;
width: ${columnWidth}px;
@ -165,11 +173,14 @@ export const RecordTableHeaderCell = ({
onMouseUp: handleResizeHandlerEnd,
});
const isRecordTableScrolledLeft = useRecoilComponentValue(
isRecordTableScrolledLeftComponentState,
);
const isMobile = useIsMobile();
const scrollLeft = useRecoilValue(scrollLeftState);
const disableColumnResize =
column.isLabelIdentifier && isMobile && scrollLeft > 0;
column.isLabelIdentifier && isMobile && !isRecordTableScrolledLeft;
return (
<StyledColumnHeaderCell
@ -185,7 +196,7 @@ export const RecordTableHeaderCell = ({
onMouseLeave={() => setIconVisibility(false)}
>
<StyledColumnHeadContainer>
<ColumnHeadWithDropdown column={column} />
<RecordTableColumnHeadWithDropdown column={column} />
{(useIsMobile() || iconVisibility) && !!column.isLabelIdentifier && (
<StyledHeaderIcon>
<LightIconButton

View File

@ -2,9 +2,9 @@ import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { Checkbox } from '@/ui/input/components/Checkbox';
import { useRecordTable } from '../hooks/useRecordTable';
import { useTheme } from '@emotion/react';
const StyledContainer = styled.div`
align-items: center;
@ -16,7 +16,7 @@ const StyledContainer = styled.div`
background-color: ${({ theme }) => theme.background.primary};
`;
export const SelectAllCheckbox = () => {
export const RecordTableHeaderCheckboxColumn = () => {
const { allRowsSelectedStatusSelector } = useRecordTableStates();
const allRowsSelectedStatus = useRecoilValue(allRowsSelectedStatusSelector());
@ -36,13 +36,26 @@ export const SelectAllCheckbox = () => {
}
};
const theme = useTheme();
return (
<StyledContainer>
<Checkbox
checked={checked}
onChange={onChange}
indeterminate={indeterminate}
/>
</StyledContainer>
<th
style={{
borderBottom: `1px solid ${theme.border.color.light}`,
borderTop: `1px solid ${theme.border.color.light}`,
width: 30,
minWidth: 30,
maxWidth: 30,
borderRight: 'transparent',
}}
>
<StyledContainer>
<Checkbox
checked={checked}
onChange={onChange}
indeterminate={indeterminate}
/>
</StyledContainer>
</th>
);
};

View File

@ -0,0 +1,10 @@
import { styled } from '@linaria/react';
const StyledTh = styled.th`
border-bottom: none;
border-top: none;
`;
export const RecordTableHeaderDragDropColumn = () => {
return <StyledTh></StyledTh>;
};

View File

@ -0,0 +1,89 @@
import { Theme } from '@emotion/react';
import styled from '@emotion/styled';
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { IconPlus, ThemeContext } from 'twenty-ui';
import { HIDDEN_TABLE_COLUMN_DROPDOWN_ID } from '@/object-record/record-table/constants/HiddenTableColumnDropdownId';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { RecordTableHeaderPlusButtonContent } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderPlusButtonContent';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useScrollWrapperScopedRef } from '@/ui/utilities/scroll/hooks/useScrollWrapperScopedRef';
const StyledPlusIconHeaderCell = styled.th<{
theme: Theme;
isTableWiderThanScreen: boolean;
}>`
${({ theme }) => {
return `
&:hover {
background: ${theme.background.transparent.light};
};
padding-left: ${theme.spacing(3)};
`;
}};
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
border-top: 1px solid ${({ theme }) => theme.border.color.light};
background-color: ${({ theme }) => theme.background.primary};
border-left: none !important;
color: ${({ theme }) => theme.font.color.tertiary};
min-width: 32px;
border-right: none !important;
${({ isTableWiderThanScreen, theme }) =>
isTableWiderThanScreen
? `
width: 32px;
background-color: ${theme.background.primary};
`
: ''};
z-index: 1;
`;
const StyledPlusIconContainer = styled.div`
align-items: center;
display: flex;
height: 32px;
justify-content: center;
width: 32px;
`;
const HIDDEN_TABLE_COLUMN_DROPDOWN_HOTKEY_SCOPE_ID =
'hidden-table-columns-dropdown-hotkey-scope-id';
export const RecordTableHeaderLastColumn = () => {
const { theme } = useContext(ThemeContext);
const scrollWrapper = useScrollWrapperScopedRef();
const isTableWiderThanScreen =
(scrollWrapper.current?.clientWidth ?? 0) <
(scrollWrapper.current?.scrollWidth ?? 0);
const { hiddenTableColumnsSelector } = useRecordTableStates();
const hiddenTableColumns = useRecoilValue(hiddenTableColumnsSelector());
return (
<StyledPlusIconHeaderCell
theme={theme}
isTableWiderThanScreen={isTableWiderThanScreen}
>
{hiddenTableColumns.length > 0 && (
<Dropdown
dropdownId={HIDDEN_TABLE_COLUMN_DROPDOWN_ID}
clickableComponent={
<StyledPlusIconContainer>
<IconPlus size={theme.icon.size.md} />
</StyledPlusIconContainer>
}
dropdownComponents={<RecordTableHeaderPlusButtonContent />}
dropdownPlacement="bottom-start"
dropdownHotkeyScope={{
scope: HIDDEN_TABLE_COLUMN_DROPDOWN_HOTKEY_SCOPE_ID,
}}
/>
)}
</StyledPlusIconHeaderCell>
);
};

View File

@ -0,0 +1,17 @@
import { useContext } from 'react';
import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext';
import { RecordTableCellsEmpty } from '@/object-record/record-table/record-table-row/components/RecordTableCellsEmpty';
import { RecordTableCellsVisible } from '@/object-record/record-table/record-table-row/components/RecordTableCellsVisible';
export const RecordTableCells = () => {
const { inView, isDragging } = useContext(RecordTableRowContext);
const areCellsVisible = inView || isDragging;
return areCellsVisible ? (
<RecordTableCellsVisible />
) : (
<RecordTableCellsEmpty />
);
};

View File

@ -0,0 +1,17 @@
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { RecordTableTd } from '@/object-record/record-table/record-table-cell/components/RecordTableTd';
import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
export const RecordTableCellsEmpty = () => {
const { isSelected } = useContext(RecordTableRowContext);
const { visibleTableColumnsSelector } = useRecordTableStates();
const visibleTableColumns = useRecoilValue(visibleTableColumnsSelector());
return visibleTableColumns.map((column) => (
<RecordTableTd isSelected={isSelected} key={column.fieldMetadataId} />
));
};

View File

@ -0,0 +1,39 @@
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { RecordTableCell } from '@/object-record/record-table/record-table-cell/components/RecordTableCell';
import { RecordTableCellWrapper } from '@/object-record/record-table/record-table-cell/components/RecordTableCellWrapper';
import { RecordTableTd } from '@/object-record/record-table/record-table-cell/components/RecordTableTd';
export const RecordTableCellsVisible = () => {
const { isDragging } = useContext(RecordTableRowContext);
const { visibleTableColumnsSelector } = useRecordTableStates();
const visibleTableColumns = useRecoilValue(visibleTableColumnsSelector());
const tableColumnsAfterFirst = visibleTableColumns.slice(1);
return (
<>
<RecordTableCellWrapper column={visibleTableColumns[0]} columnIndex={0}>
<RecordTableTd>
<RecordTableCell />
</RecordTableTd>
</RecordTableCellWrapper>
{!isDragging &&
tableColumnsAfterFirst.map((column, columnIndex) => (
<RecordTableCellWrapper
key={column.fieldMetadataId}
column={column}
columnIndex={columnIndex + 1}
>
<RecordTableTd>
<RecordTableCell />
</RecordTableTd>
</RecordTableCellWrapper>
))}
</>
);
};

View File

@ -1,13 +1,13 @@
import { useRecoilValue } from 'recoil';
import { RecordTableRow } from '@/object-record/record-table/components/RecordTableRow';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { RecordTableRow } from '@/object-record/record-table/record-table-row/components/RecordTableRow';
export const RecordTablePendingRow = () => {
const { pendingRecordIdState } = useRecordTableStates();
const pendingRecordId = useRecoilValue(pendingRecordIdState);
if (!pendingRecordId) return;
if (!pendingRecordId) return <></>;
return (
<RecordTableRow

View File

@ -0,0 +1,32 @@
import { RecordValueSetterEffect } from '@/object-record/record-store/components/RecordValueSetterEffect';
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 { RecordTableCells } from '@/object-record/record-table/record-table-row/components/RecordTableCells';
import { RecordTableRowWrapper } from '@/object-record/record-table/record-table-row/components/RecordTableRowWrapper';
type RecordTableRowProps = {
recordId: string;
rowIndex: number;
isPendingRow?: boolean;
};
export const RecordTableRow = ({
recordId,
rowIndex,
isPendingRow,
}: RecordTableRowProps) => {
return (
<RecordTableRowWrapper
recordId={recordId}
rowIndex={rowIndex}
isPendingRow={isPendingRow}
>
<RecordTableCellGrip />
<RecordTableCellCheckbox />
<RecordTableCells />
<RecordTableLastEmptyCell />
<RecordValueSetterEffect recordId={recordId} />
</RecordTableRowWrapper>
);
};

View File

@ -0,0 +1,87 @@
import { ReactNode, useContext } from 'react';
import { useInView } from 'react-intersection-observer';
import { useTheme } from '@emotion/react';
import { Draggable } from '@hello-pangea/dnd';
import { useRecoilValue } from 'recoil';
import { getBasePathToShowPage } from '@/object-metadata/utils/getBasePathToShowPage';
import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { RecordTableTr } from '@/object-record/record-table/record-table-row/components/RecordTableTr';
import { ScrollWrapperContext } from '@/ui/utilities/scroll/components/ScrollWrapper';
export const RecordTableRowWrapper = ({
recordId,
rowIndex,
isPendingRow,
children,
}: {
recordId: string;
rowIndex: number;
isPendingRow?: boolean;
children: ReactNode;
}) => {
const { objectMetadataItem } = useContext(RecordTableContext);
const theme = useTheme();
const { isRowSelectedFamilyState } = useRecordTableStates();
const currentRowSelected = useRecoilValue(isRowSelectedFamilyState(recordId));
const scrollWrapperRef = useContext(ScrollWrapperContext);
const { ref: elementRef, inView } = useInView({
root: scrollWrapperRef.current?.querySelector(
'[data-overlayscrollbars-viewport="scrollbarHidden"]',
),
rootMargin: '1000px',
});
return (
<Draggable key={recordId} draggableId={recordId} index={rowIndex}>
{(draggableProvided, draggableSnapshot) => (
<RecordTableTr
ref={(node) => {
elementRef(node);
draggableProvided.innerRef(node);
}}
// eslint-disable-next-line react/jsx-props-no-spreading
{...draggableProvided.draggableProps}
style={{
...draggableProvided.draggableProps.style,
background: draggableSnapshot.isDragging
? theme.background.transparent.light
: 'none',
borderColor: draggableSnapshot.isDragging
? `${theme.border.color.medium}`
: 'transparent',
}}
isDragging={draggableSnapshot.isDragging}
data-testid={`row-id-${recordId}`}
data-selectable-id={recordId}
>
<RecordTableRowContext.Provider
value={{
recordId,
rowIndex,
pathToShowPage:
getBasePathToShowPage({
objectNameSingular: objectMetadataItem.nameSingular,
}) + recordId,
objectNameSingular: objectMetadataItem.nameSingular,
isSelected: currentRowSelected,
isReadOnly: objectMetadataItem.isRemote ?? false,
isPendingRow,
isDragging: draggableSnapshot.isDragging,
dragHandleProps: draggableProvided.dragHandleProps,
inView,
}}
>
{children}
</RecordTableRowContext.Provider>
</RecordTableTr>
)}
</Draggable>
);
};

View File

@ -0,0 +1,11 @@
import styled from '@emotion/styled';
const StyledTr = styled.tr<{ isDragging: boolean }>`
border: ${({ isDragging, theme }) =>
isDragging
? `1px solid ${theme.border.color.medium}`
: '1px solid transparent'};
transition: border-left-color 0.2s ease-in-out;
`;
export const RecordTableTr = StyledTr;

View File

@ -0,0 +1,9 @@
import { RecordTableScopeInternalContext } from '@/object-record/record-table/scopes/scope-internal-context/RecordTableScopeInternalContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const isRecordTableScrolledLeftComponentState =
createComponentStateV2<boolean>({
key: 'isRecordTableScrolledLeftComponentState',
componentContext: RecordTableScopeInternalContext,
defaultValue: true,
});

View File

@ -0,0 +1,9 @@
import { RecordTableScopeInternalContext } from '@/object-record/record-table/scopes/scope-internal-context/RecordTableScopeInternalContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const isRecordTableScrolledTopComponentState =
createComponentStateV2<boolean>({
key: 'isRecordTableScrolledTopComponentState',
componentContext: RecordTableScopeInternalContext,
defaultValue: true,
});

View File

@ -1,12 +1,13 @@
import { useRef } from 'react';
import { Keys } from 'react-hotkeys-hook';
import {
autoUpdate,
flip,
FloatingPortal,
offset,
Placement,
useFloating,
} from '@floating-ui/react';
import { useRef } from 'react';
import { Keys } from 'react-hotkeys-hook';
import { Key } from 'ts-key-enum';
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
@ -85,7 +86,7 @@ export const Dropdown = ({
};
useListenClickOutside({
refs: [containerRef],
refs: [refs.floating],
callback: () => {
onClickOutside?.();
@ -131,15 +132,17 @@ export const Dropdown = ({
/>
)}
{isDropdownOpen && (
<DropdownMenu
disableBlur={disableBlur}
width={dropdownMenuWidth ?? dropdownWidth}
data-select-disable
ref={refs.setFloating}
style={floatingStyles}
>
{dropdownComponents}
</DropdownMenu>
<FloatingPortal>
<DropdownMenu
disableBlur={disableBlur}
width={dropdownMenuWidth ?? dropdownWidth}
data-select-disable
ref={refs.setFloating}
style={floatingStyles}
>
{dropdownComponents}
</DropdownMenu>
</FloatingPortal>
)}
<DropdownOnToggleEffect
onDropdownClose={onClose}

View File

@ -1,10 +1,10 @@
import { ReactElement, useCallback, useEffect, useRef, useState } from 'react';
import styled from '@emotion/styled';
import { ReactElement, useCallback, useEffect, useRef, useState } from 'react';
import { Chip, ChipVariant } from 'twenty-ui';
import { AnimatedContainer } from '@/object-record/record-table/components/AnimatedContainer';
import { ExpandedListDropdown } from '@/ui/layout/expandable-list/components/ExpandedListDropdown';
import { isFirstOverflowingChildElement } from '@/ui/layout/expandable-list/utils/isFirstOverflowingChildElement';
import { AnimatedContainer } from '@/ui/utilities/animation/components/AnimatedContainer';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { isDefined } from '~/utils/isDefined';

View File

@ -1,20 +1,17 @@
import React from 'react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
const StyledAnimatedChipContainer = styled(motion.div)``;
import React from 'react';
export const AnimatedContainer = ({
children,
}: {
children: React.ReactNode;
}) => (
<StyledAnimatedChipContainer
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.1 }}
whileHover={{ scale: 1.04 }}
>
{children}
</StyledAnimatedChipContainer>
</motion.div>
);

View File

@ -0,0 +1,29 @@
import { useRecoilValue } from 'recoil';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
import { getScopeIdOrUndefinedFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdOrUndefinedFromComponentId';
import { ComponentState } from '@/ui/utilities/state/component-state/types/ComponentState';
export const useRecoilComponentValue = <StateType>(
componentState: ComponentState<StateType>,
componentId?: string,
) => {
const componentContext = (window as any).componentContextStateMap?.get(
componentState.key,
);
if (!componentContext) {
throw new Error(
`Component context for key "${componentState.key}" is not defined`,
);
}
const internalComponentId = useAvailableScopeIdOrThrow(
componentContext,
getScopeIdOrUndefinedFromComponentId(componentId),
);
return useRecoilValue(
componentState.atomFamily({ scopeId: internalComponentId }),
);
};

View File

@ -0,0 +1,29 @@
import { useSetRecoilState } from 'recoil';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
import { getScopeIdOrUndefinedFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdOrUndefinedFromComponentId';
import { ComponentState } from '@/ui/utilities/state/component-state/types/ComponentState';
export const useSetRecoilComponentState = <StateType>(
componentState: ComponentState<StateType>,
componentId?: string,
) => {
const componentContext = (window as any).componentContextStateMap?.get(
componentState.key,
);
if (!componentContext) {
throw new Error(
`Component context for key "${componentState.key}" is not defined`,
);
}
const internalComponentId = useAvailableScopeIdOrThrow(
componentContext,
getScopeIdOrUndefinedFromComponentId(componentId),
);
return useSetRecoilState(
componentState.atomFamily({ scopeId: internalComponentId }),
);
};

View File

@ -0,0 +1,8 @@
import { RecoilState } from 'recoil';
import { ComponentStateKey } from '@/ui/utilities/state/component-state/types/ComponentStateKey';
export type ComponentState<StateType> = {
key: string;
atomFamily: (componentStateKey: ComponentStateKey) => RecoilState<StateType>;
};

View File

@ -2,15 +2,17 @@ import { AtomEffect, atomFamily } from 'recoil';
import { ComponentStateKey } from '@/ui/utilities/state/component-state/types/ComponentStateKey';
type CreateComponentStateType<ValueType> = {
key: string;
defaultValue: ValueType;
effects?: AtomEffect<ValueType>[];
};
export const createComponentState = <ValueType>({
key,
defaultValue,
effects,
}: {
key: string;
defaultValue: ValueType;
effects?: AtomEffect<ValueType>[];
}) => {
}: CreateComponentStateType<ValueType>) => {
return atomFamily<ValueType, ComponentStateKey>({
key,
default: defaultValue,

View File

@ -0,0 +1,37 @@
import { AtomEffect, atomFamily } from 'recoil';
import { ScopeInternalContext } from '@/ui/utilities/recoil-scope/scopes-internal/types/ScopeInternalContext';
import { ComponentState } from '@/ui/utilities/state/component-state/types/ComponentState';
import { ComponentStateKey } from '@/ui/utilities/state/component-state/types/ComponentStateKey';
import { isDefined } from '~/utils/isDefined';
type CreateComponentStateV2Type<ValueType> = {
key: string;
defaultValue: ValueType;
componentContext?: ScopeInternalContext<any> | null;
effects?: AtomEffect<ValueType>[];
};
export const createComponentStateV2 = <ValueType>({
key,
defaultValue,
componentContext,
effects,
}: CreateComponentStateV2Type<ValueType>): ComponentState<ValueType> => {
if (isDefined(componentContext)) {
if (!isDefined((window as any).componentContextStateMap)) {
(window as any).componentContextStateMap = new Map();
}
(window as any).componentContextStateMap.set(key, componentContext);
}
return {
key,
atomFamily: atomFamily<ValueType, ComponentStateKey>({
key,
default: defaultValue,
effects: effects,
}),
};
};

View File

@ -67,6 +67,11 @@ export default defineConfig(({ command, mode }) => {
'**/RecordTableCellContainer.tsx',
'**/RecordTableCellDisplayContainer.tsx',
'**/Avatar.tsx',
'**/RecordTableBodyDroppable.tsx',
'**/RecordTableCellBaseContainer.tsx',
'**/RecordTableCellTd.tsx',
'**/RecordTableTd.tsx',
'**/RecordTableHeaderDragDropColumn.tsx',
],
babelOptions: {
presets: ['@babel/preset-typescript', '@babel/preset-react'],