feat: add Table and TableSection components (#1849)

* refactor: rename ui/table to ui/data-table

* feat: add Table and TableSection components

Closes #1806
This commit is contained in:
Thaïs
2023-10-04 17:46:14 +02:00
committed by GitHub
parent d217142e7e
commit 7af306792b
118 changed files with 236 additions and 67 deletions

View File

@ -0,0 +1,48 @@
import { useCurrentTableCellEditMode } from '../table-cell/hooks/useCurrentTableCellEditMode';
import { useTableCell } from '../table-cell/hooks/useTableCell';
import { useMoveSoftFocus } from './useMoveSoftFocus';
export const useCellInputEventHandlers = <T>({
onSubmit,
onCancel,
}: {
onSubmit?: (newValue: T) => void;
onCancel?: () => void;
}) => {
const { closeTableCell: closeEditableCell } = useTableCell();
const { isCurrentTableCellInEditMode: isCurrentCellInEditMode } =
useCurrentTableCellEditMode();
const { moveRight, moveLeft, moveDown } = useMoveSoftFocus();
return {
handleClickOutside: (event: MouseEvent | TouchEvent, newValue: T) => {
if (isCurrentCellInEditMode) {
event.stopImmediatePropagation();
onSubmit?.(newValue);
closeEditableCell();
}
},
handleEscape: () => {
closeEditableCell();
onCancel?.();
},
handleEnter: (newValue: T) => {
onSubmit?.(newValue);
closeEditableCell();
moveDown();
},
handleTab: (newValue: T) => {
onSubmit?.(newValue);
closeEditableCell();
moveRight();
},
handleShiftTab: (newValue: T) => {
onSubmit?.(newValue);
closeEditableCell();
moveLeft();
},
};
};

View File

@ -0,0 +1,18 @@
import { useRecoilCallback } from 'recoil';
import { currentTableCellInEditModePositionState } from '../states/currentTableCellInEditModePositionState';
import { isTableCellInEditModeFamilyState } from '../states/isTableCellInEditModeFamilyState';
export const useCloseCurrentTableCellInEditMode = () =>
useRecoilCallback(({ set, snapshot }) => {
return async () => {
const currentTableCellInEditModePosition = await snapshot.getPromise(
currentTableCellInEditModePositionState,
);
set(
isTableCellInEditModeFamilyState(currentTableCellInEditModePosition),
false,
);
};
}, []);

View File

@ -0,0 +1,9 @@
import { useContext } from 'react';
import { RowIdContext } from '../contexts/RowIdContext';
export const useCurrentRowEntityId = () => {
const currentEntityId = useContext(RowIdContext);
return currentEntityId;
};

View File

@ -0,0 +1,36 @@
import { useContext } from 'react';
import { useRecoilCallback, useRecoilState } from 'recoil';
import { RowIdContext } from '../contexts/RowIdContext';
import { isRowSelectedFamilyState } from '../states/isRowSelectedFamilyState';
export const useCurrentRowSelected = () => {
const currentRowId = useContext(RowIdContext);
const [isRowSelected] = useRecoilState(
isRowSelectedFamilyState(currentRowId ?? ''),
);
const setCurrentRowSelected = useRecoilCallback(
({ set, snapshot }) =>
(newSelectedState: boolean) => {
if (!currentRowId) return;
const isRowSelected = snapshot
.getLoadable(isRowSelectedFamilyState(currentRowId))
.valueOrThrow();
if (newSelectedState && !isRowSelected) {
set(isRowSelectedFamilyState(currentRowId), true);
} else if (!newSelectedState && isRowSelected) {
set(isRowSelectedFamilyState(currentRowId), false);
}
},
[currentRowId],
);
return {
currentRowSelected: isRowSelected,
setCurrentRowSelected,
};
};

View File

@ -0,0 +1,18 @@
import { useRecoilCallback } from 'recoil';
import { isSoftFocusActiveState } from '../states/isSoftFocusActiveState';
import { isSoftFocusOnTableCellFamilyState } from '../states/isSoftFocusOnTableCellFamilyState';
import { softFocusPositionState } from '../states/softFocusPositionState';
export const useDisableSoftFocus = () =>
useRecoilCallback(({ set, snapshot }) => {
return () => {
const currentPosition = snapshot
.getLoadable(softFocusPositionState)
.valueOrThrow();
set(isSoftFocusActiveState, false);
set(isSoftFocusOnTableCellFamilyState(currentPosition), false);
};
}, []);

View File

@ -0,0 +1,39 @@
import { useRecoilCallback } from 'recoil';
import { currentHotkeyScopeState } from '@/ui/utilities/hotkey/states/internal/currentHotkeyScopeState';
import { isSoftFocusActiveState } from '../states/isSoftFocusActiveState';
import { TableHotkeyScope } from '../types/TableHotkeyScope';
import { useCloseCurrentTableCellInEditMode } from './useCloseCurrentTableCellInEditMode';
import { useDisableSoftFocus } from './useDisableSoftFocus';
export const useLeaveTableFocus = () => {
const disableSoftFocus = useDisableSoftFocus();
const closeCurrentCellInEditMode = useCloseCurrentTableCellInEditMode();
return useRecoilCallback(
({ snapshot }) =>
() => {
const isSoftFocusActive = snapshot
.getLoadable(isSoftFocusActiveState)
.valueOrThrow();
const currentHotkeyScope = snapshot
.getLoadable(currentHotkeyScopeState)
.valueOrThrow();
if (!isSoftFocusActive) {
return;
}
if (currentHotkeyScope?.scope === TableHotkeyScope.Table) {
return;
}
closeCurrentCellInEditMode();
disableSoftFocus();
},
[closeCurrentCellInEditMode, disableSoftFocus],
);
};

View File

@ -0,0 +1,62 @@
import { Key } from 'ts-key-enum';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { TableHotkeyScope } from '../types/TableHotkeyScope';
import { useDisableSoftFocus } from './useDisableSoftFocus';
import { useMoveSoftFocus } from './useMoveSoftFocus';
export const useMapKeyboardToSoftFocus = () => {
const { moveDown, moveLeft, moveRight, moveUp } = useMoveSoftFocus();
const disableSoftFocus = useDisableSoftFocus();
const setHotkeyScope = useSetHotkeyScope();
useScopedHotkeys(
[Key.ArrowUp, `${Key.Shift}+${Key.Enter}`],
() => {
moveUp();
},
TableHotkeyScope.TableSoftFocus,
[moveUp],
);
useScopedHotkeys(
Key.ArrowDown,
() => {
moveDown();
},
TableHotkeyScope.TableSoftFocus,
[moveDown],
);
useScopedHotkeys(
[Key.ArrowLeft, `${Key.Shift}+${Key.Tab}`],
() => {
moveLeft();
},
TableHotkeyScope.TableSoftFocus,
[moveLeft],
);
useScopedHotkeys(
[Key.ArrowRight, Key.Tab],
() => {
moveRight();
},
TableHotkeyScope.TableSoftFocus,
[moveRight],
);
useScopedHotkeys(
[Key.Escape],
() => {
setHotkeyScope(TableHotkeyScope.Table, { goto: true });
disableSoftFocus();
},
TableHotkeyScope.TableSoftFocus,
[disableSoftFocus],
);
};

View File

@ -0,0 +1,23 @@
import { useRecoilCallback } from 'recoil';
import { currentTableCellInEditModePositionState } from '../states/currentTableCellInEditModePositionState';
import { isTableCellInEditModeFamilyState } from '../states/isTableCellInEditModeFamilyState';
import { TableCellPosition } from '../types/TableCellPosition';
export const useMoveEditModeToTableCellPosition = () =>
useRecoilCallback(({ set, snapshot }) => {
return (newPosition: TableCellPosition) => {
const currentTableCellInEditModePosition = snapshot
.getLoadable(currentTableCellInEditModePositionState)
.valueOrThrow();
set(
isTableCellInEditModeFamilyState(currentTableCellInEditModePosition),
false,
);
set(currentTableCellInEditModePositionState, newPosition);
set(isTableCellInEditModeFamilyState(newPosition), true);
};
}, []);

View File

@ -0,0 +1,159 @@
import { useRecoilCallback } from 'recoil';
import { useRecoilScopeId } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopeId';
import { numberOfTableRowsState } from '../states/numberOfTableRowsState';
import { TableRecoilScopeContext } from '../states/recoil-scope-contexts/TableRecoilScopeContext';
import { numberOfTableColumnsScopedSelector } from '../states/selectors/numberOfTableColumnsScopedSelector';
import { softFocusPositionState } from '../states/softFocusPositionState';
import { useSetSoftFocusPosition } from './useSetSoftFocusPosition';
// TODO: stories
export const useMoveSoftFocus = () => {
const tableScopeId = useRecoilScopeId(TableRecoilScopeContext);
const setSoftFocusPosition = useSetSoftFocusPosition();
const moveUp = useRecoilCallback(
({ snapshot }) =>
() => {
const softFocusPosition = snapshot
.getLoadable(softFocusPositionState)
.valueOrThrow();
let newRowNumber = softFocusPosition.row - 1;
if (newRowNumber < 0) {
newRowNumber = 0;
}
setSoftFocusPosition({
...softFocusPosition,
row: newRowNumber,
});
},
[setSoftFocusPosition],
);
const moveDown = useRecoilCallback(
({ snapshot }) =>
() => {
const softFocusPosition = snapshot
.getLoadable(softFocusPositionState)
.valueOrThrow();
const numberOfTableRows = snapshot
.getLoadable(numberOfTableRowsState)
.valueOrThrow();
let newRowNumber = softFocusPosition.row + 1;
if (newRowNumber >= numberOfTableRows) {
newRowNumber = numberOfTableRows - 1;
}
setSoftFocusPosition({
...softFocusPosition,
row: newRowNumber,
});
},
[setSoftFocusPosition],
);
const moveRight = useRecoilCallback(
({ snapshot }) =>
() => {
const softFocusPosition = snapshot
.getLoadable(softFocusPositionState)
.valueOrThrow();
const numberOfTableColumns = snapshot
.getLoadable(numberOfTableColumnsScopedSelector(tableScopeId))
.valueOrThrow();
const numberOfTableRows = snapshot
.getLoadable(numberOfTableRowsState)
.valueOrThrow();
const currentColumnNumber = softFocusPosition.column;
const currentRowNumber = softFocusPosition.row;
const isLastRowAndLastColumn =
currentColumnNumber === numberOfTableColumns - 1 &&
currentRowNumber === numberOfTableRows - 1;
const isLastColumnButNotLastRow =
currentColumnNumber === numberOfTableColumns - 1 &&
currentRowNumber !== numberOfTableRows - 1;
const isNotLastColumn =
currentColumnNumber !== numberOfTableColumns - 1;
if (isLastRowAndLastColumn) {
return;
}
if (isNotLastColumn) {
setSoftFocusPosition({
row: currentRowNumber,
column: currentColumnNumber + 1,
});
} else if (isLastColumnButNotLastRow) {
setSoftFocusPosition({
row: currentRowNumber + 1,
column: 0,
});
}
},
[setSoftFocusPosition, tableScopeId],
);
const moveLeft = useRecoilCallback(
({ snapshot }) =>
() => {
const softFocusPosition = snapshot
.getLoadable(softFocusPositionState)
.valueOrThrow();
const numberOfTableColumns = snapshot
.getLoadable(numberOfTableColumnsScopedSelector(tableScopeId))
.valueOrThrow();
const currentColumnNumber = softFocusPosition.column;
const currentRowNumber = softFocusPosition.row;
const isFirstRowAndFirstColumn =
currentColumnNumber === 0 && currentRowNumber === 0;
const isFirstColumnButNotFirstRow =
currentColumnNumber === 0 && currentRowNumber > 0;
const isNotFirstColumn = currentColumnNumber > 0;
if (isFirstRowAndFirstColumn) {
return;
}
if (isNotFirstColumn) {
setSoftFocusPosition({
row: currentRowNumber,
column: currentColumnNumber - 1,
});
} else if (isFirstColumnButNotFirstRow) {
setSoftFocusPosition({
row: currentRowNumber - 1,
column: numberOfTableColumns - 1,
});
}
},
[setSoftFocusPosition, tableScopeId],
);
return {
moveDown,
moveLeft,
moveRight,
moveUp,
};
};

View File

@ -0,0 +1,19 @@
import { useRecoilCallback } from 'recoil';
import { isRowSelectedFamilyState } from '../states/isRowSelectedFamilyState';
import { tableRowIdsState } from '../states/tableRowIdsState';
export const useResetTableRowSelection = () =>
useRecoilCallback(
({ snapshot, set }) =>
() => {
const tableRowIds = snapshot
.getLoadable(tableRowIdsState)
.valueOrThrow();
for (const rowId of tableRowIds) {
set(isRowSelectedFamilyState(rowId), false);
}
},
[],
);

View File

@ -0,0 +1,41 @@
import { useRecoilCallback, useRecoilValue } from 'recoil';
import { isRowSelectedFamilyState } from '../states/isRowSelectedFamilyState';
import { allRowsSelectedStatusSelector } from '../states/selectors/allRowsSelectedStatusSelector';
import { tableRowIdsState } from '../states/tableRowIdsState';
export const useSelectAllRows = () => {
const allRowsSelectedStatus = useRecoilValue(allRowsSelectedStatusSelector);
const selectAllRows = useRecoilCallback(
({ set, snapshot }) =>
() => {
const allRowsSelectedStatus = snapshot
.getLoadable(allRowsSelectedStatusSelector)
.valueOrThrow();
const tableRowIds = snapshot
.getLoadable(tableRowIdsState)
.valueOrThrow();
if (
allRowsSelectedStatus === 'none' ||
allRowsSelectedStatus === 'some'
) {
for (const rowId of tableRowIds) {
set(isRowSelectedFamilyState(rowId), true);
}
} else {
for (const rowId of tableRowIds) {
set(isRowSelectedFamilyState(rowId), false);
}
}
},
[],
);
return {
allRowsSelectedStatus,
selectAllRows,
};
};

View File

@ -0,0 +1,70 @@
import { useRecoilCallback } from 'recoil';
import { entityFieldsFamilyState } from '@/ui/field/states/entityFieldsFamilyState';
import { useRecoilScopeId } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopeId';
import { availableFiltersScopedState } from '@/ui/view-bar/states/availableFiltersScopedState';
import { availableSortsScopedState } from '@/ui/view-bar/states/availableSortsScopedState';
import { entityCountInCurrentViewState } from '@/ui/view-bar/states/entityCountInCurrentViewState';
import { FilterDefinition } from '@/ui/view-bar/types/FilterDefinition';
import { SortDefinition } from '@/ui/view-bar/types/SortDefinition';
import { isFetchingEntityTableDataState } from '../states/isFetchingEntityTableDataState';
import { numberOfTableRowsState } from '../states/numberOfTableRowsState';
import { TableRecoilScopeContext } from '../states/recoil-scope-contexts/TableRecoilScopeContext';
import { tableRowIdsState } from '../states/tableRowIdsState';
import { useResetTableRowSelection } from './useResetTableRowSelection';
export const useSetEntityTableData = () => {
const resetTableRowSelection = useResetTableRowSelection();
const tableContextScopeId = useRecoilScopeId(TableRecoilScopeContext);
return useRecoilCallback(
({ set, snapshot }) =>
<T extends { id: string }>(
newEntityArray: T[],
filterDefinitionArray: FilterDefinition[],
sortDefinitionArray: SortDefinition[],
) => {
for (const entity of newEntityArray) {
const currentEntity = snapshot
.getLoadable(entityFieldsFamilyState(entity.id))
.valueOrThrow();
if (JSON.stringify(currentEntity) !== JSON.stringify(entity)) {
set(entityFieldsFamilyState(entity.id), entity);
}
}
const entityIds = newEntityArray.map((entity) => entity.id);
set(tableRowIdsState, (currentRowIds) => {
if (JSON.stringify(currentRowIds) !== JSON.stringify(entityIds)) {
return entityIds;
}
return currentRowIds;
});
resetTableRowSelection();
set(numberOfTableRowsState, entityIds.length);
set(entityCountInCurrentViewState, entityIds.length);
set(
availableFiltersScopedState(tableContextScopeId),
filterDefinitionArray,
);
set(
availableSortsScopedState(tableContextScopeId),
sortDefinitionArray,
);
set(isFetchingEntityTableDataState, false);
},
[resetTableRowSelection, tableContextScopeId],
);
};

View File

@ -0,0 +1,8 @@
import { useRecoilCallback } from 'recoil';
import { isRowSelectedFamilyState } from '../states/isRowSelectedFamilyState';
export const useSetRowSelectedState = () =>
useRecoilCallback(({ set }) => (rowId: string, selected: boolean) => {
set(isRowSelectedFamilyState(rowId), selected);
});

View File

@ -0,0 +1,23 @@
import { useRecoilCallback } from 'recoil';
import { isSoftFocusActiveState } from '../states/isSoftFocusActiveState';
import { isSoftFocusOnTableCellFamilyState } from '../states/isSoftFocusOnTableCellFamilyState';
import { softFocusPositionState } from '../states/softFocusPositionState';
import { TableCellPosition } from '../types/TableCellPosition';
export const useSetSoftFocusPosition = () =>
useRecoilCallback(({ set, snapshot }) => {
return (newPosition: TableCellPosition) => {
const currentPosition = snapshot
.getLoadable(softFocusPositionState)
.valueOrThrow();
set(isSoftFocusActiveState, true);
set(isSoftFocusOnTableCellFamilyState(currentPosition), false);
set(softFocusPositionState, newPosition);
set(isSoftFocusOnTableCellFamilyState(newPosition), true);
};
}, []);

View File

@ -0,0 +1,115 @@
import { useCallback, useContext } from 'react';
import { useSetRecoilState } from 'recoil';
import { FieldMetadata } from '@/ui/field/types/FieldMetadata';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue';
import { currentViewIdScopedState } from '@/ui/view-bar/states/currentViewIdScopedState';
import { ViewFieldForVisibility } from '@/ui/view-bar/types/ViewFieldForVisibility';
import { useMoveViewColumns } from '@/views/hooks/useMoveViewColumns';
import { TableContext } from '../contexts/TableContext';
import { availableTableColumnsScopedState } from '../states/availableTableColumnsScopedState';
import { TableRecoilScopeContext } from '../states/recoil-scope-contexts/TableRecoilScopeContext';
import { savedTableColumnsFamilyState } from '../states/savedTableColumnsFamilyState';
import { tableColumnsScopedState } from '../states/tableColumnsScopedState';
import { ColumnDefinition } from '../types/ColumnDefinition';
export const useTableColumns = () => {
const { onColumnsChange } = useContext(TableContext);
const [availableTableColumns] = useRecoilScopedState(
availableTableColumnsScopedState,
TableRecoilScopeContext,
);
const currentViewId = useRecoilScopedValue(
currentViewIdScopedState,
TableRecoilScopeContext,
);
const setSavedTableColumns = useSetRecoilState(
savedTableColumnsFamilyState(currentViewId),
);
const [tableColumns, setTableColumns] = useRecoilScopedState(
tableColumnsScopedState,
TableRecoilScopeContext,
);
const { handleColumnMove } = useMoveViewColumns();
const handleColumnsChange = useCallback(
async (columns: ColumnDefinition<FieldMetadata>[]) => {
setSavedTableColumns(columns);
setTableColumns(columns);
await onColumnsChange?.(columns);
},
[onColumnsChange, setSavedTableColumns, setTableColumns],
);
const handleColumnReorder = useCallback(
async (columns: ColumnDefinition<FieldMetadata>[]) => {
const updatedColumns = columns.map((column, index) => ({
...column,
index,
}));
await handleColumnsChange(updatedColumns);
},
[handleColumnsChange],
);
const handleColumnVisibilityChange = useCallback(
async (viewField: ViewFieldForVisibility) => {
const isNewColumn = !tableColumns.some(
(tableColumns) => tableColumns.key === viewField.key,
);
if (isNewColumn) {
const newColumn = availableTableColumns.find(
(availableTableColumn) => availableTableColumn.key === viewField.key,
);
if (!newColumn) return;
const nextColumns = [
...tableColumns,
{ ...newColumn, isVisible: true },
];
await handleColumnsChange(nextColumns);
} else {
const nextColumns = tableColumns.map((previousColumn) =>
previousColumn.key === viewField.key
? { ...previousColumn, isVisible: !viewField.isVisible }
: previousColumn,
);
await handleColumnsChange(nextColumns);
}
},
[tableColumns, availableTableColumns, handleColumnsChange],
);
const handleMoveTableColumn = useCallback(
(direction: 'left' | 'right', column: ColumnDefinition<FieldMetadata>) => {
const currentColumnArrayIndex = tableColumns.findIndex(
(tableColumn) => tableColumn.key === column.key,
);
const columns = handleColumnMove(
direction,
currentColumnArrayIndex,
tableColumns,
);
setTableColumns(columns);
},
[tableColumns, setTableColumns, handleColumnMove],
);
return {
handleColumnVisibilityChange,
handleMoveTableColumn,
handleColumnReorder,
handleColumnsChange,
};
};

View File

@ -0,0 +1,18 @@
import { useRecoilCallback } from 'recoil';
import { entityFieldsFamilyState } from '@/ui/field/states/entityFieldsFamilyState';
export const useUpsertEntityTableItem = () =>
useRecoilCallback(
({ set, snapshot }) =>
<T extends { id: string }>(entity: T) => {
const currentEntity = snapshot
.getLoadable(entityFieldsFamilyState(entity.id))
.valueOrThrow();
if (JSON.stringify(currentEntity) !== JSON.stringify(entity)) {
set(entityFieldsFamilyState(entity.id), entity);
}
},
[],
);

View File

@ -0,0 +1,33 @@
import { useRecoilCallback } from 'recoil';
import { entityFieldsFamilyState } from '@/ui/field/states/entityFieldsFamilyState';
export const useUpsertEntityTableItems = () =>
useRecoilCallback(
({ set, snapshot }) =>
<T extends { id: string }>(entities: T[]) => {
// Create a map of new entities for quick lookup.
const newEntityMap = new Map(
entities.map((entity) => [entity.id, entity]),
);
// Filter out entities that are already the same in the state.
const entitiesToUpdate = entities.filter((entity) => {
const currentEntity = snapshot
.getLoadable(entityFieldsFamilyState(entity.id))
.valueMaybe();
return (
!currentEntity ||
JSON.stringify(currentEntity) !==
JSON.stringify(newEntityMap.get(entity.id))
);
});
// Batch set state for the filtered entities.
for (const entity of entitiesToUpdate) {
set(entityFieldsFamilyState(entity.id), entity);
}
},
[],
);

View File

@ -0,0 +1,16 @@
import { useRecoilCallback } from 'recoil';
import { tableRowIdsState } from '../states/tableRowIdsState';
export const useUpsertTableRowId = () =>
useRecoilCallback(
({ set, snapshot }) =>
(rowId: string) => {
const currentRowIds = snapshot
.getLoadable(tableRowIdsState)
.valueOrThrow();
set(tableRowIdsState, Array.from(new Set([rowId, ...currentRowIds])));
},
[],
);

View File

@ -0,0 +1,17 @@
import { useRecoilCallback } from 'recoil';
import { tableRowIdsState } from '../states/tableRowIdsState';
export const useUpsertTableRowIds = () =>
useRecoilCallback(
({ set, snapshot }) =>
(rowIds: string[]) => {
const currentRowIds = snapshot
.getLoadable(tableRowIdsState)
.valueOrThrow();
const uniqueRowIds = Array.from(new Set([...rowIds, ...currentRowIds]));
set(tableRowIdsState, uniqueRowIds);
},
[],
);