Refactor/remove react table (#642)

* Refactored tables without tan stack
* Fixed checkbox behavior with multiple handlers on click
* Fixed hotkeys scope
* Fix debounce in editable cells
* Lowered coverage

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Lucas Bordeau
2023-07-13 19:08:13 +02:00
committed by GitHub
parent e7d48d5373
commit 734e18e01a
88 changed files with 1789 additions and 671 deletions

View File

@ -0,0 +1,9 @@
import Skeleton from 'react-loading-skeleton';
export function CellSkeleton() {
return (
<div style={{ width: '100%', alignItems: 'center' }}>
<Skeleton />
</div>
);
}

View File

@ -1,7 +1,8 @@
import { ReactElement } from 'react';
import { ReactElement, useState } from 'react';
import { InternalHotkeysScope } from '@/hotkeys/types/internal/InternalHotkeysScope';
import { CellSkeleton } from '../CellSkeleton';
import { EditableCell } from '../EditableCell';
import { EditableCellDoubleTextEditMode } from './EditableCellDoubleTextEditMode';
@ -13,6 +14,7 @@ type OwnProps = {
secondValuePlaceholder: string;
nonEditModeContent: ReactElement;
onChange: (firstValue: string, secondValue: string) => void;
loading?: boolean;
};
export function EditableCellDoubleText({
@ -22,20 +24,30 @@ export function EditableCellDoubleText({
secondValuePlaceholder,
onChange,
nonEditModeContent,
loading,
}: OwnProps) {
const [firstInternalValue, setFirstInternalValue] = useState(firstValue);
const [secondInternalValue, setSecondInternalValue] = useState(secondValue);
function handleOnChange(firstValue: string, secondValue: string): void {
setFirstInternalValue(firstValue);
setSecondInternalValue(secondValue);
onChange(firstValue, secondValue);
}
return (
<EditableCell
editHotkeysScope={{ scope: InternalHotkeysScope.CellDoubleTextInput }}
editModeContent={
<EditableCellDoubleTextEditMode
firstValue={firstValue}
secondValue={secondValue}
firstValue={firstInternalValue}
secondValue={secondInternalValue}
firstValuePlaceholder={firstValuePlaceholder}
secondValuePlaceholder={secondValuePlaceholder}
onChange={onChange}
onChange={handleOnChange}
/>
}
nonEditModeContent={nonEditModeContent}
nonEditModeContent={loading ? <CellSkeleton /> : nonEditModeContent}
></EditableCell>
);
}

View File

@ -1,4 +1,4 @@
import { ChangeEvent, useRef, useState } from 'react';
import { ChangeEvent, useEffect, useRef, useState } from 'react';
import { InplaceInputPhoneDisplayMode } from '@/ui/inplace-inputs/components/InplaceInputPhoneDisplayMode';
import { InplaceInputTextEditMode } from '@/ui/inplace-inputs/components/InplaceInputTextEditMode';
@ -8,17 +8,17 @@ import { EditableCell } from '../EditableCell';
type OwnProps = {
placeholder?: string;
value: string;
changeHandler: (updated: string) => void;
onChange: (updated: string) => void;
};
export function EditableCellPhone({
value,
placeholder,
changeHandler,
}: OwnProps) {
export function EditableCellPhone({ value, placeholder, onChange }: OwnProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = useState(value);
useEffect(() => {
setInputValue(value);
}, [value]);
return (
<EditableCell
editModeContent={
@ -29,7 +29,7 @@ export function EditableCellPhone({
value={inputValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
changeHandler(event.target.value);
onChange(event.target.value);
}}
/>
}

View File

@ -1,9 +1,9 @@
import { ChangeEvent, useMemo, useState } from 'react';
import { ChangeEvent, useEffect, useState } from 'react';
import { InplaceInputTextDisplayMode } from '@/ui/inplace-inputs/components/InplaceInputTextDisplayMode';
import { InplaceInputTextEditMode } from '@/ui/inplace-inputs/components/InplaceInputTextEditMode';
import { debounce } from '@/utils/debounce';
import { CellSkeleton } from '../CellSkeleton';
import { EditableCell } from '../EditableCell';
type OwnProps = {
@ -11,6 +11,7 @@ type OwnProps = {
value: string;
onChange: (newValue: string) => void;
editModeHorizontalAlign?: 'left' | 'right';
loading?: boolean;
};
export function EditableCellText({
@ -18,12 +19,13 @@ export function EditableCellText({
placeholder,
onChange,
editModeHorizontalAlign,
loading,
}: OwnProps) {
const [internalValue, setInternalValue] = useState(value);
const debouncedOnChange = useMemo(() => {
return debounce(onChange, 200);
}, [onChange]);
useEffect(() => {
setInternalValue(value);
}, [value]);
return (
<EditableCell
@ -35,14 +37,18 @@ export function EditableCellText({
value={internalValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setInternalValue(event.target.value);
debouncedOnChange(event.target.value);
onChange(event.target.value);
}}
/>
}
nonEditModeContent={
<InplaceInputTextDisplayMode>
{internalValue}
</InplaceInputTextDisplayMode>
loading ? (
<CellSkeleton />
) : (
<InplaceInputTextDisplayMode>
{internalValue}
</InplaceInputTextDisplayMode>
)
}
></EditableCell>
);

View File

@ -1,4 +1,11 @@
import { ChangeEvent, ComponentType, ReactNode, useRef, useState } from 'react';
import {
ChangeEvent,
ComponentType,
ReactNode,
useEffect,
useRef,
useState,
} from 'react';
import styled from '@emotion/styled';
import { textInputStyle } from '@/ui/themes/effects';
@ -55,6 +62,10 @@ export function EditableCellChip({
const inputRef = useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = useState(value);
useEffect(() => {
setInputValue(value);
}, [value]);
const handleRightEndContentClick = (
event: React.MouseEvent<HTMLDivElement>,
) => {

View File

@ -2,9 +2,7 @@ import * as React from 'react';
import styled from '@emotion/styled';
type OwnProps = {
name?: string;
id?: string;
checked?: boolean;
checked: boolean;
indeterminate?: boolean;
onChange?: (newCheckedValue: boolean) => void;
};
@ -41,13 +39,7 @@ const StyledContainer = styled.div`
}
`;
export function Checkbox({
name,
id,
checked,
onChange,
indeterminate,
}: OwnProps) {
export function Checkbox({ checked, onChange, indeterminate }: OwnProps) {
const ref = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
@ -57,10 +49,8 @@ export function Checkbox({
}
}, [ref, indeterminate, checked]);
function handleInputChange(event: React.ChangeEvent<HTMLInputElement>) {
if (onChange) {
onChange(event.target.checked);
}
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
onChange?.(event.target.checked);
}
return (
@ -69,10 +59,8 @@ export function Checkbox({
ref={ref}
type="checkbox"
data-testid="input-checkbox"
id={id}
name={name}
checked={checked}
onChange={handleInputChange}
onChange={handleChange}
/>
</StyledContainer>
);

View File

@ -35,7 +35,6 @@ const StyledChildrenContainer = styled.div`
export function DropdownMenuCheckableItem({
checked,
onChange,
id,
children,
}: React.PropsWithChildren<Props>) {
function handleClick() {
@ -45,7 +44,7 @@ export function DropdownMenuCheckableItem({
return (
<DropdownMenuCheckableItemContainer onClick={handleClick}>
<StyledLeftContainer>
<Checkbox id={id} name={id} checked={checked} />
<Checkbox checked={checked} />
<StyledChildrenContainer>{children}</StyledChildrenContainer>
</StyledLeftContainer>
</DropdownMenuCheckableItemContainer>

View File

@ -2,18 +2,11 @@ import * as React from 'react';
import styled from '@emotion/styled';
import { useSetRecoilState } from 'recoil';
import { useCurrentRowSelected } from '@/ui/tables/hooks/useCurrentRowSelected';
import { contextMenuPositionState } from '@/ui/tables/states/contextMenuPositionState';
import { Checkbox } from '../form/Checkbox';
type OwnProps = {
name: string;
id: string;
checked?: boolean;
indeterminate?: boolean;
onChange?: (newCheckedValue: boolean) => void;
};
const StyledContainer = styled.div`
align-items: center;
@ -24,31 +17,19 @@ const StyledContainer = styled.div`
justify-content: center;
`;
export function CheckboxCell({
name,
id,
checked,
onChange,
indeterminate,
}: OwnProps) {
const [internalChecked, setInternalChecked] = React.useState(checked);
export function CheckboxCell() {
const setContextMenuPosition = useSetRecoilState(contextMenuPositionState);
const { currentRowSelected, setCurrentRowSelected } = useCurrentRowSelected();
function handleContainerClick() {
handleCheckboxChange(!internalChecked);
handleCheckboxChange(!currentRowSelected);
}
React.useEffect(() => {
setInternalChecked(checked);
}, [checked]);
function handleCheckboxChange(newCheckedValue: boolean) {
setInternalChecked(newCheckedValue);
setContextMenuPosition({ x: null, y: null });
setCurrentRowSelected(newCheckedValue);
if (onChange) {
onChange(newCheckedValue);
}
setContextMenuPosition({ x: null, y: null });
}
return (
@ -56,13 +37,7 @@ export function CheckboxCell({
onClick={handleContainerClick}
data-testid="input-checkbox-cell-container"
>
<Checkbox
id={id}
name={name}
checked={internalChecked}
onChange={handleCheckboxChange}
indeterminate={indeterminate}
/>
<Checkbox checked={currentRowSelected} />
</StyledContainer>
);
}

View File

@ -1,36 +1,17 @@
import * as React from 'react';
import styled from '@emotion/styled';
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import { useRecoilState } from 'recoil';
import {
SelectedSortType,
SortType,
} from '@/lib/filters-and-sorts/interfaces/sorts/interface';
import { RecoilScope } from '@/recoil-scope/components/RecoilScope';
import { TableColumn } from '@/people/table/components/peopleColumns';
import { useListenClickOutsideArrayOfRef } from '@/ui/hooks/useListenClickOutsideArrayOfRef';
import { useLeaveTableFocus } from '@/ui/tables/hooks/useLeaveTableFocus';
import { RowContext } from '@/ui/tables/states/RowContext';
import { currentRowSelectionState } from '../../tables/states/rowSelectionState';
import { TableHeader } from './table-header/TableHeader';
import { EntityTableRow } from './EntityTableRow';
type OwnProps<TData extends { id: string }, SortField> = {
data: Array<TData>;
columns: Array<ColumnDef<TData, any>>;
viewName: string;
viewIcon?: React.ReactNode;
availableSorts?: Array<SortType<SortField>>;
onSortsUpdate?: (sorts: Array<SelectedSortType<SortField>>) => void;
onRowSelectionChange?: (rowSelection: string[]) => void;
};
import { EntityTableBody } from './EntityTableBody';
import { EntityTableHeader } from './EntityTableHeader';
const StyledTable = styled.table`
border-collapse: collapse;
@ -91,32 +72,24 @@ const StyledTableWithHeader = styled.div`
width: 100%;
`;
export function EntityTable<TData extends { id: string }, SortField>({
data,
type OwnProps<SortField> = {
columns: Array<TableColumn>;
viewName: string;
viewIcon?: React.ReactNode;
availableSorts?: Array<SortType<SortField>>;
onSortsUpdate?: (sorts: Array<SelectedSortType<SortField>>) => void;
onRowSelectionChange?: (rowSelection: string[]) => void;
};
export function EntityTable<SortField>({
columns,
viewName,
viewIcon,
availableSorts,
onSortsUpdate,
}: OwnProps<TData, SortField>) {
}: OwnProps<SortField>) {
const tableBodyRef = React.useRef<HTMLDivElement>(null);
const [currentRowSelection, setCurrentRowSelection] = useRecoilState(
currentRowSelectionState,
);
const table = useReactTable<TData>({
data,
columns,
state: {
rowSelection: currentRowSelection,
},
getCoreRowModel: getCoreRowModel(),
enableRowSelection: true,
onRowSelectionChange: setCurrentRowSelection,
getRowId: (row) => row.id,
});
const leaveTableFocus = useLeaveTableFocus();
useListenClickOutsideArrayOfRef([tableBodyRef], () => {
@ -133,37 +106,8 @@ export function EntityTable<TData extends { id: string }, SortField>({
/>
<div ref={tableBodyRef}>
<StyledTable>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
style={{
width: header.column.getSize(),
minWidth: header.column.getSize(),
maxWidth: header.column.getSize(),
}}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
<th></th>
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row, index) => (
<RecoilScope SpecificContext={RowContext} key={row.id}>
<EntityTableRow row={row} index={index} />
</RecoilScope>
))}
</tbody>
<EntityTableHeader columns={columns} />
<EntityTableBody columns={columns} />
</StyledTable>
</div>
</StyledTableWithHeader>

View File

@ -0,0 +1,33 @@
import { useRecoilValue } from 'recoil';
import { TableColumn } from '@/people/table/components/peopleColumns';
import { RecoilScope } from '@/recoil-scope/components/RecoilScope';
import { isFetchingEntityTableDataState } from '@/ui/tables/states/isFetchingEntityTableDataState';
import { RowContext } from '@/ui/tables/states/RowContext';
import { tableRowIdsState } from '@/ui/tables/states/tableRowIdsState';
import { EntityTableRow } from './EntityTableRow';
export function EntityTableBody({ columns }: { columns: Array<TableColumn> }) {
const rowIds = useRecoilValue(tableRowIdsState);
const isFetchingEntityTableData = useRecoilValue(
isFetchingEntityTableDataState,
);
return (
<tbody>
{!isFetchingEntityTableData ? (
rowIds.map((rowId, index) => (
<RecoilScope SpecificContext={RowContext} key={rowId}>
<EntityTableRow columns={columns} rowId={rowId} index={index} />
</RecoilScope>
))
) : (
<tr>
<td>loading...</td>
</tr>
)}
</tbody>
);
}

View File

@ -1,6 +1,4 @@
import { useEffect } from 'react';
import { flexRender } from '@tanstack/react-table';
import { Cell, Row } from '@tanstack/table-core';
import { useRecoilState, useSetRecoilState } from 'recoil';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
@ -9,14 +7,16 @@ import { contextMenuPositionState } from '@/ui/tables/states/contextMenuPosition
import { currentColumnNumberScopedState } from '@/ui/tables/states/currentColumnNumberScopedState';
import { currentRowSelectionState } from '@/ui/tables/states/rowSelectionState';
export function EntityTableCell<TData extends { id: string }>({
row,
cell,
export function EntityTableCell({
rowId,
cellIndex,
children,
size,
}: {
row: Row<TData>;
cell: Cell<TData, unknown>;
size: number;
rowId: string;
cellIndex: number;
children: React.ReactNode;
}) {
const [, setCurrentRowSelection] = useRecoilState(currentRowSelectionState);
@ -43,14 +43,14 @@ export function EntityTableCell<TData extends { id: string }>({
return (
<td
onContextMenu={(event) => handleContextMenu(event, row.original.id)}
onContextMenu={(event) => handleContextMenu(event, rowId)}
style={{
width: cell.column.getSize(),
minWidth: cell.column.getSize(),
maxWidth: cell.column.getSize(),
width: size,
minWidth: size,
maxWidth: size,
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
{children}
</td>
);
}

View File

@ -0,0 +1,39 @@
import { TableColumn } from '@/people/table/components/peopleColumns';
import { ColumnHead } from './ColumnHead';
import { SelectAllCheckbox } from './SelectAllCheckbox';
export function EntityTableHeader({
columns,
}: {
columns: Array<TableColumn>;
}) {
return (
<thead>
<tr>
<th
style={{
width: 30,
minWidth: 30,
maxWidth: 30,
}}
>
<SelectAllCheckbox />
</th>
{columns.map((column) => (
<th
key={column.id.toString()}
style={{
width: column.size,
minWidth: column.size,
maxWidth: column.size,
}}
>
<ColumnHead viewName={column.title} viewIcon={column.icon} />
</th>
))}
<th></th>
</tr>
</thead>
);
}

View File

@ -1,15 +1,17 @@
import { useEffect } from 'react';
import styled from '@emotion/styled';
import { Row } from '@tanstack/table-core';
import { useRecoilState } from 'recoil';
import { TableColumn } from '@/people/table/components/peopleColumns';
import { RecoilScope } from '@/recoil-scope/components/RecoilScope';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { CellContext } from '@/ui/tables/states/CellContext';
import { currentRowEntityIdScopedState } from '@/ui/tables/states/currentRowEntityIdScopedState';
import { currentRowNumberScopedState } from '@/ui/tables/states/currentRowNumberScopedState';
import { RowContext } from '@/ui/tables/states/RowContext';
import { currentRowSelectionState } from '@/ui/tables/states/rowSelectionState';
import { CheckboxCell } from './CheckboxCell';
import { EntityTableCell } from './EntityTableCell';
const StyledRow = styled.tr<{ selected: boolean }>`
@ -17,42 +19,56 @@ const StyledRow = styled.tr<{ selected: boolean }>`
props.selected ? props.theme.background.secondary : 'none'};
`;
export function EntityTableRow<TData extends { id: string }>({
row,
export function EntityTableRow({
columns,
rowId,
index,
}: {
row: Row<TData>;
columns: TableColumn[];
rowId: string;
index: number;
}) {
const [currentRowSelection] = useRecoilState(currentRowSelectionState);
const [currentRowEntityId, setCurrentRowEntityId] = useRecoilScopedState(
currentRowEntityIdScopedState,
RowContext,
);
const [, setCurrentRowNumber] = useRecoilScopedState(
currentRowNumberScopedState,
RowContext,
);
useEffect(() => {
if (currentRowEntityId !== rowId) {
setCurrentRowEntityId(rowId);
}
}, [rowId, setCurrentRowEntityId, currentRowEntityId]);
useEffect(() => {
setCurrentRowNumber(index);
}, [index, setCurrentRowNumber]);
return (
<StyledRow
key={row.id}
data-testid={`row-id-${row.index}`}
selected={!!currentRowSelection[row.id]}
key={rowId}
data-testid={`row-id-${rowId}`}
selected={!!currentRowSelection[rowId]}
>
{row.getVisibleCells().map((cell, cellIndex) => {
<td>
<CheckboxCell />
</td>
{columns.map((column, columnIndex) => {
return (
<RecoilScope
SpecificContext={CellContext}
key={cell.id + row.original.id}
>
<RecoilScope SpecificContext={CellContext} key={column.id.toString()}>
<RecoilScope>
<EntityTableCell<TData>
row={row}
cell={cell}
cellIndex={cellIndex}
/>
<EntityTableCell
rowId={rowId}
size={column.size}
cellIndex={columnIndex}
>
{column.cellComponent}
</EntityTableCell>
</RecoilScope>
</RecoilScope>
);

View File

@ -5,22 +5,19 @@ import { useMapKeyboardToSoftFocus } from '@/ui/tables/hooks/useMapKeyboardToSof
export function HooksEntityTable({
numberOfColumns,
numberOfRows,
availableTableFilters,
availableFilters,
}: {
numberOfColumns: number;
numberOfRows: number;
availableTableFilters: FilterDefinition[];
availableFilters: FilterDefinition[];
}) {
useMapKeyboardToSoftFocus();
useInitializeEntityTable({
numberOfColumns,
numberOfRows,
});
useInitializeEntityTableFilters({
availableTableFilters,
availableFilters,
});
return <></>;

View File

@ -1,18 +1,36 @@
import { CheckboxCell } from './CheckboxCell';
import React from 'react';
import styled from '@emotion/styled';
import { useSelectAllRows } from '@/ui/tables/hooks/useSelectAllRows';
import { Checkbox } from '../form/Checkbox';
const StyledContainer = styled.div`
align-items: center;
cursor: pointer;
display: flex;
height: 32px;
justify-content: center;
`;
export const SelectAllCheckbox = () => {
const { selectAllRows, allRowsSelectedStatus } = useSelectAllRows();
function handleContainerClick() {
selectAllRows();
}
const checked = allRowsSelectedStatus === 'all';
const indeterminate = allRowsSelectedStatus === 'some';
export const SelectAllCheckbox = ({
indeterminate,
onChange,
}: {
indeterminate?: boolean;
onChange?: (newCheckedValue: boolean) => void;
} & React.HTMLProps<HTMLInputElement>) => {
return (
<CheckboxCell
name="select-all-checkbox"
id="select-all-checkbox"
indeterminate={indeterminate}
onChange={onChange}
/>
<StyledContainer
onClick={handleContainerClick}
data-testid="input-checkbox-cell-container"
>
<Checkbox checked={checked} indeterminate={indeterminate} />
</StyledContainer>
);
};