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:
34
front/src/modules/ui/data-table/components/CheckboxCell.tsx
Normal file
34
front/src/modules/ui/data-table/components/CheckboxCell.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { useCallback } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
|
||||
import { actionBarOpenState } from '@/ui/action-bar/states/actionBarIsOpenState';
|
||||
import { Checkbox } from '@/ui/input/components/Checkbox';
|
||||
|
||||
import { useCurrentRowSelected } from '../hooks/useCurrentRowSelected';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
height: 32px;
|
||||
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
export const CheckboxCell = () => {
|
||||
const setActionBarOpenState = useSetRecoilState(actionBarOpenState);
|
||||
const { currentRowSelected, setCurrentRowSelected } = useCurrentRowSelected();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setCurrentRowSelected(!currentRowSelected);
|
||||
setActionBarOpenState(true);
|
||||
}, [currentRowSelected, setActionBarOpenState, setCurrentRowSelected]);
|
||||
|
||||
return (
|
||||
<StyledContainer onClick={handleClick}>
|
||||
<Checkbox checked={currentRowSelected} />
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
72
front/src/modules/ui/data-table/components/ColumnHead.tsx
Normal file
72
front/src/modules/ui/data-table/components/ColumnHead.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { useDropdownButton } from '@/ui/dropdown/hooks/useDropdownButton';
|
||||
import { FieldMetadata } from '@/ui/field/types/FieldMetadata';
|
||||
|
||||
import { ColumnDefinition } from '../types/ColumnDefinition';
|
||||
|
||||
import { EntityTableHeaderOptions } from './EntityTableHeaderOptions';
|
||||
|
||||
type OwnProps = {
|
||||
column: ColumnDefinition<FieldMetadata>;
|
||||
isFirstColumn: boolean;
|
||||
isLastColumn: boolean;
|
||||
primaryColumnKey: string;
|
||||
};
|
||||
|
||||
const StyledTitle = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
height: ${({ theme }) => theme.spacing(8)};
|
||||
padding-left: ${({ theme }) => theme.spacing(2)};
|
||||
padding-right: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledIcon = styled.div`
|
||||
display: flex;
|
||||
|
||||
& > svg {
|
||||
height: ${({ theme }) => theme.icon.size.md}px;
|
||||
width: ${({ theme }) => theme.icon.size.md}px;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledText = styled.span`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export const ColumnHead = ({
|
||||
column,
|
||||
isFirstColumn,
|
||||
isLastColumn,
|
||||
primaryColumnKey,
|
||||
}: OwnProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const { openDropdownButton } = useDropdownButton({
|
||||
dropdownId: column.key + '-header',
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledTitle onClick={openDropdownButton}>
|
||||
<StyledIcon>
|
||||
{column.Icon && <column.Icon size={theme.icon.size.md} />}
|
||||
</StyledIcon>
|
||||
<StyledText>{column.name}</StyledText>
|
||||
</StyledTitle>
|
||||
<EntityTableHeaderOptions
|
||||
column={column}
|
||||
isFirstColumn={isFirstColumn}
|
||||
isLastColumn={isLastColumn}
|
||||
primaryColumnKey={primaryColumnKey}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
144
front/src/modules/ui/data-table/components/EntityTable.tsx
Normal file
144
front/src/modules/ui/data-table/components/EntityTable.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
import { useRef } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect';
|
||||
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
||||
import {
|
||||
useListenClickOutside,
|
||||
useListenClickOutsideByClassName,
|
||||
} from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
|
||||
|
||||
import { EntityUpdateMutationContext } from '../contexts/EntityUpdateMutationHookContext';
|
||||
import { useLeaveTableFocus } from '../hooks/useLeaveTableFocus';
|
||||
import { useMapKeyboardToSoftFocus } from '../hooks/useMapKeyboardToSoftFocus';
|
||||
import { useResetTableRowSelection } from '../hooks/useResetTableRowSelection';
|
||||
import { useSetRowSelectedState } from '../hooks/useSetRowSelectedState';
|
||||
import { TableHeader } from '../table-header/components/TableHeader';
|
||||
import { TableHotkeyScope } from '../types/TableHotkeyScope';
|
||||
|
||||
import { EntityTableBody } from './EntityTableBody';
|
||||
import { EntityTableHeader } from './EntityTableHeader';
|
||||
|
||||
const StyledTable = styled.table`
|
||||
border-collapse: collapse;
|
||||
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
border-spacing: 0;
|
||||
margin-left: ${({ theme }) => theme.table.horizontalCellMargin};
|
||||
margin-right: ${({ theme }) => theme.table.horizontalCellMargin};
|
||||
table-layout: fixed;
|
||||
|
||||
width: calc(100% - ${({ theme }) => theme.table.horizontalCellMargin} * 2);
|
||||
|
||||
th {
|
||||
border: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
border-collapse: collapse;
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
|
||||
:last-child {
|
||||
border-right-color: transparent;
|
||||
}
|
||||
:first-of-type {
|
||||
border-left-color: transparent;
|
||||
border-right-color: transparent;
|
||||
}
|
||||
:last-of-type {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
border: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
border-collapse: collapse;
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
padding: 0;
|
||||
|
||||
text-align: left;
|
||||
|
||||
:last-child {
|
||||
border-right-color: transparent;
|
||||
}
|
||||
:first-of-type {
|
||||
border-left-color: transparent;
|
||||
border-right-color: transparent;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledTableWithHeader = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledTableContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
`;
|
||||
|
||||
type OwnProps = {
|
||||
updateEntityMutation: (params: any) => void;
|
||||
};
|
||||
|
||||
export const EntityTable = ({ updateEntityMutation }: OwnProps) => {
|
||||
const tableBodyRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const setRowSelectedState = useSetRowSelectedState();
|
||||
const resetTableRowSelection = useResetTableRowSelection();
|
||||
|
||||
useMapKeyboardToSoftFocus();
|
||||
|
||||
const leaveTableFocus = useLeaveTableFocus();
|
||||
|
||||
useListenClickOutside({
|
||||
refs: [tableBodyRef],
|
||||
callback: () => {
|
||||
leaveTableFocus();
|
||||
},
|
||||
});
|
||||
|
||||
useScopedHotkeys(
|
||||
'escape',
|
||||
() => {
|
||||
resetTableRowSelection();
|
||||
},
|
||||
TableHotkeyScope.Table,
|
||||
);
|
||||
|
||||
useListenClickOutsideByClassName({
|
||||
classNames: ['entity-table-cell'],
|
||||
excludeClassNames: ['action-bar', 'context-menu'],
|
||||
callback: () => {
|
||||
resetTableRowSelection();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<EntityUpdateMutationContext.Provider value={updateEntityMutation}>
|
||||
<StyledTableWithHeader>
|
||||
<StyledTableContainer ref={tableBodyRef}>
|
||||
<TableHeader />
|
||||
<ScrollWrapper>
|
||||
<div>
|
||||
<StyledTable className="entity-table-cell">
|
||||
<EntityTableHeader />
|
||||
<EntityTableBody />
|
||||
</StyledTable>
|
||||
</div>
|
||||
</ScrollWrapper>
|
||||
<DragSelect
|
||||
dragSelectable={tableBodyRef}
|
||||
onDragSelectionStart={resetTableRowSelection}
|
||||
onDragSelectionChange={setRowSelectedState}
|
||||
/>
|
||||
</StyledTableContainer>
|
||||
</StyledTableWithHeader>
|
||||
</EntityUpdateMutationContext.Provider>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,81 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { useVirtual } from '@tanstack/react-virtual';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { isNavbarSwitchingSizeState } from '@/ui/layout/states/isNavbarSwitchingSizeState';
|
||||
import { useScrollWrapperScopedRef } from '@/ui/utilities/scroll/hooks/useScrollWrapperScopedRef';
|
||||
|
||||
import { RowIdContext } from '../contexts/RowIdContext';
|
||||
import { RowIndexContext } from '../contexts/RowIndexContext';
|
||||
import { isFetchingEntityTableDataState } from '../states/isFetchingEntityTableDataState';
|
||||
import { tableRowIdsState } from '../states/tableRowIdsState';
|
||||
|
||||
import { EntityTableRow } from './EntityTableRow';
|
||||
|
||||
type SpaceProps = {
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
};
|
||||
|
||||
const StyledSpace = styled.td<SpaceProps>`
|
||||
${({ top }) => top && `padding-top: ${top}px;`}
|
||||
${({ bottom }) => bottom && `padding-bottom: ${bottom}px;`}
|
||||
`;
|
||||
|
||||
export const EntityTableBody = () => {
|
||||
const scrollWrapperRef = useScrollWrapperScopedRef();
|
||||
|
||||
const tableRowIds = useRecoilValue(tableRowIdsState);
|
||||
|
||||
const isNavbarSwitchingSize = useRecoilValue(isNavbarSwitchingSizeState);
|
||||
const isFetchingEntityTableData = useRecoilValue(
|
||||
isFetchingEntityTableDataState,
|
||||
);
|
||||
|
||||
const rowVirtualizer = useVirtual({
|
||||
size: tableRowIds.length,
|
||||
parentRef: scrollWrapperRef,
|
||||
overscan: 50,
|
||||
});
|
||||
|
||||
const items = rowVirtualizer.virtualItems;
|
||||
const paddingTop = items.length > 0 ? items[0].start : 0;
|
||||
const paddingBottom =
|
||||
items.length > 0
|
||||
? rowVirtualizer.totalSize - items[items.length - 1].end
|
||||
: 0;
|
||||
|
||||
if (isFetchingEntityTableData || isNavbarSwitchingSize) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<tbody>
|
||||
{paddingTop > 0 && (
|
||||
<tr>
|
||||
<StyledSpace top={paddingTop} />
|
||||
</tr>
|
||||
)}
|
||||
{items.map((virtualItem) => {
|
||||
const rowId = tableRowIds[virtualItem.index];
|
||||
|
||||
return (
|
||||
<RowIdContext.Provider value={rowId} key={rowId}>
|
||||
<RowIndexContext.Provider value={virtualItem.index}>
|
||||
<EntityTableRow
|
||||
key={virtualItem.index}
|
||||
ref={virtualItem.measureRef}
|
||||
rowId={rowId}
|
||||
/>
|
||||
</RowIndexContext.Provider>
|
||||
</RowIdContext.Provider>
|
||||
);
|
||||
})}
|
||||
{paddingBottom > 0 && (
|
||||
<tr>
|
||||
<StyledSpace bottom={paddingBottom} />
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,67 @@
|
||||
import { useContext } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
|
||||
import { contextMenuIsOpenState } from '@/ui/context-menu/states/contextMenuIsOpenState';
|
||||
import { contextMenuPositionState } from '@/ui/context-menu/states/contextMenuPositionState';
|
||||
import { FieldContext } from '@/ui/field/contexts/FieldContext';
|
||||
import { isFieldRelation } from '@/ui/field/types/guards/isFieldRelation';
|
||||
import { RelationPickerHotkeyScope } from '@/ui/input/relation-picker/types/RelationPickerHotkeyScope';
|
||||
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
|
||||
|
||||
import { ColumnContext } from '../contexts/ColumnContext';
|
||||
import { ColumnIndexContext } from '../contexts/ColumnIndexContext';
|
||||
import { EntityUpdateMutationContext } from '../contexts/EntityUpdateMutationHookContext';
|
||||
import { RowIdContext } from '../contexts/RowIdContext';
|
||||
import { useCurrentRowSelected } from '../hooks/useCurrentRowSelected';
|
||||
import { TableCell } from '../table-cell/components/TableCell';
|
||||
import { TableHotkeyScope } from '../types/TableHotkeyScope';
|
||||
|
||||
export const EntityTableCell = ({ cellIndex }: { cellIndex: number }) => {
|
||||
const setContextMenuPosition = useSetRecoilState(contextMenuPositionState);
|
||||
const setContextMenuOpenState = useSetRecoilState(contextMenuIsOpenState);
|
||||
const currentRowId = useContext(RowIdContext);
|
||||
|
||||
const { setCurrentRowSelected } = useCurrentRowSelected();
|
||||
|
||||
const handleContextMenu = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
setCurrentRowSelected(true);
|
||||
setContextMenuPosition({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
setContextMenuOpenState(true);
|
||||
};
|
||||
|
||||
const columnDefinition = useContext(ColumnContext);
|
||||
|
||||
const updateEntityMutation = useContext(EntityUpdateMutationContext);
|
||||
|
||||
if (!columnDefinition || !currentRowId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const customHotkeyScope = isFieldRelation(columnDefinition)
|
||||
? RelationPickerHotkeyScope.RelationPicker
|
||||
: TableHotkeyScope.CellEditMode;
|
||||
|
||||
return (
|
||||
<RecoilScope>
|
||||
<ColumnIndexContext.Provider value={cellIndex}>
|
||||
<td onContextMenu={(event) => handleContextMenu(event)}>
|
||||
<FieldContext.Provider
|
||||
value={{
|
||||
recoilScopeId: currentRowId + columnDefinition.name,
|
||||
entityId: currentRowId,
|
||||
fieldDefinition: columnDefinition,
|
||||
useUpdateEntityMutation: () => [updateEntityMutation, {}],
|
||||
hotkeyScope: customHotkeyScope,
|
||||
}}
|
||||
>
|
||||
<TableCell customHotkeyScope={{ scope: customHotkeyScope }} />
|
||||
</FieldContext.Provider>
|
||||
</td>
|
||||
</ColumnIndexContext.Provider>
|
||||
</RecoilScope>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,73 @@
|
||||
import { ComponentProps, useCallback, useRef } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { StyledDropdownMenu } from '@/ui/dropdown/components/StyledDropdownMenu';
|
||||
import { StyledDropdownMenuItemsContainer } from '@/ui/dropdown/components/StyledDropdownMenuItemsContainer';
|
||||
import { FieldMetadata } from '@/ui/field/types/FieldMetadata';
|
||||
import { IconPlus } from '@/ui/icon';
|
||||
import { MenuItem } from '@/ui/menu-item/components/MenuItem';
|
||||
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||
import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue';
|
||||
|
||||
import { useTableColumns } from '../hooks/useTableColumns';
|
||||
import { TableRecoilScopeContext } from '../states/recoil-scope-contexts/TableRecoilScopeContext';
|
||||
import { hiddenTableColumnsScopedSelector } from '../states/selectors/hiddenTableColumnsScopedSelector';
|
||||
import { ColumnDefinition } from '../types/ColumnDefinition';
|
||||
|
||||
const StyledColumnMenu = styled(StyledDropdownMenu)`
|
||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||
`;
|
||||
|
||||
type EntityTableColumnMenuProps = {
|
||||
onAddColumn?: () => void;
|
||||
onClickOutside?: () => void;
|
||||
} & ComponentProps<'div'>;
|
||||
|
||||
export const EntityTableColumnMenu = ({
|
||||
onAddColumn,
|
||||
onClickOutside = () => undefined,
|
||||
...props
|
||||
}: EntityTableColumnMenuProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const hiddenTableColumns = useRecoilScopedValue(
|
||||
hiddenTableColumnsScopedSelector,
|
||||
TableRecoilScopeContext,
|
||||
);
|
||||
|
||||
useListenClickOutside({
|
||||
refs: [ref],
|
||||
callback: onClickOutside,
|
||||
});
|
||||
|
||||
const { handleColumnVisibilityChange } = useTableColumns();
|
||||
|
||||
const handleAddColumn = useCallback(
|
||||
(column: ColumnDefinition<FieldMetadata>) => {
|
||||
onAddColumn?.();
|
||||
handleColumnVisibilityChange(column);
|
||||
},
|
||||
[handleColumnVisibilityChange, onAddColumn],
|
||||
);
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line twenty/no-spread-props
|
||||
<StyledColumnMenu {...props} ref={ref}>
|
||||
<StyledDropdownMenuItemsContainer>
|
||||
{hiddenTableColumns.map((column) => (
|
||||
<MenuItem
|
||||
key={column.key}
|
||||
iconButtons={[
|
||||
{
|
||||
Icon: IconPlus,
|
||||
onClick: () => handleAddColumn(column),
|
||||
},
|
||||
]}
|
||||
LeftIcon={column.Icon}
|
||||
text={column.name}
|
||||
/>
|
||||
))}
|
||||
</StyledDropdownMenuItemsContainer>
|
||||
</StyledColumnMenu>
|
||||
);
|
||||
};
|
||||
106
front/src/modules/ui/data-table/components/EntityTableEffect.tsx
Normal file
106
front/src/modules/ui/data-table/components/EntityTableEffect.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
|
||||
import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect';
|
||||
import { OptimisticEffectDefinition } from '@/apollo/optimistic-effect/types/OptimisticEffectDefinition';
|
||||
import { useRecoilScopeId } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopeId';
|
||||
import { currentViewIdScopedState } from '@/ui/view-bar/states/currentViewIdScopedState';
|
||||
import { filtersScopedState } from '@/ui/view-bar/states/filtersScopedState';
|
||||
import { savedFiltersFamilyState } from '@/ui/view-bar/states/savedFiltersFamilyState';
|
||||
import { savedSortsFamilyState } from '@/ui/view-bar/states/savedSortsFamilyState';
|
||||
import { sortsScopedState } from '@/ui/view-bar/states/sortsScopedState';
|
||||
import { FilterDefinition } from '@/ui/view-bar/types/FilterDefinition';
|
||||
import { SortDefinition } from '@/ui/view-bar/types/SortDefinition';
|
||||
import { SortOrder } from '~/generated/graphql';
|
||||
|
||||
import { useSetEntityTableData } from '../hooks/useSetEntityTableData';
|
||||
import { TableRecoilScopeContext } from '../states/recoil-scope-contexts/TableRecoilScopeContext';
|
||||
|
||||
export const EntityTableEffect = ({
|
||||
useGetRequest,
|
||||
getRequestResultKey,
|
||||
getRequestOptimisticEffectDefinition,
|
||||
orderBy = [
|
||||
{
|
||||
createdAt: SortOrder.Desc,
|
||||
},
|
||||
],
|
||||
whereFilters,
|
||||
filterDefinitionArray,
|
||||
setActionBarEntries,
|
||||
setContextMenuEntries,
|
||||
sortDefinitionArray,
|
||||
}: {
|
||||
// TODO: type this
|
||||
useGetRequest: any;
|
||||
getRequestResultKey: string;
|
||||
getRequestOptimisticEffectDefinition: OptimisticEffectDefinition<any>;
|
||||
// TODO: type this and replace with defaultSorts reduce should be applied to defaultSorts in this component not before
|
||||
orderBy?: any;
|
||||
// TODO: type this and replace with defaultFilters reduce should be applied to defaultFilters in this component not before
|
||||
whereFilters?: any;
|
||||
filterDefinitionArray: FilterDefinition[];
|
||||
sortDefinitionArray: SortDefinition[];
|
||||
setActionBarEntries?: () => void;
|
||||
setContextMenuEntries?: () => void;
|
||||
}) => {
|
||||
const setEntityTableData = useSetEntityTableData();
|
||||
const { registerOptimisticEffect } = useOptimisticEffect();
|
||||
|
||||
useGetRequest({
|
||||
variables: { orderBy, where: whereFilters },
|
||||
onCompleted: (data: any) => {
|
||||
const entities = data[getRequestResultKey] ?? [];
|
||||
|
||||
setEntityTableData(entities, filterDefinitionArray, sortDefinitionArray);
|
||||
|
||||
registerOptimisticEffect({
|
||||
variables: { orderBy, where: whereFilters },
|
||||
definition: getRequestOptimisticEffectDefinition,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const tableRecoilScopeId = useRecoilScopeId(TableRecoilScopeContext);
|
||||
const handleViewSelect = useRecoilCallback(
|
||||
({ set, snapshot }) =>
|
||||
async (viewId: string) => {
|
||||
const currentView = await snapshot.getPromise(
|
||||
currentViewIdScopedState(tableRecoilScopeId),
|
||||
);
|
||||
if (currentView === viewId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const savedFilters = await snapshot.getPromise(
|
||||
savedFiltersFamilyState(viewId),
|
||||
);
|
||||
const savedSorts = await snapshot.getPromise(
|
||||
savedSortsFamilyState(viewId),
|
||||
);
|
||||
|
||||
set(filtersScopedState(tableRecoilScopeId), savedFilters);
|
||||
set(sortsScopedState(tableRecoilScopeId), savedSorts);
|
||||
set(currentViewIdScopedState(tableRecoilScopeId), viewId);
|
||||
},
|
||||
[tableRecoilScopeId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const viewId = searchParams.get('view');
|
||||
if (viewId) {
|
||||
handleViewSelect(viewId);
|
||||
}
|
||||
setActionBarEntries?.();
|
||||
setContextMenuEntries?.();
|
||||
}, [
|
||||
handleViewSelect,
|
||||
searchParams,
|
||||
setActionBarEntries,
|
||||
setContextMenuEntries,
|
||||
]);
|
||||
|
||||
return <></>;
|
||||
};
|
||||
226
front/src/modules/ui/data-table/components/EntityTableHeader.tsx
Normal file
226
front/src/modules/ui/data-table/components/EntityTableHeader.tsx
Normal file
@ -0,0 +1,226 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useRecoilCallback, useRecoilState } from 'recoil';
|
||||
|
||||
import { IconButton } from '@/ui/button/components/IconButton';
|
||||
import { DropdownRecoilScopeContext } from '@/ui/dropdown/states/recoil-scope-contexts/DropdownRecoilScopeContext';
|
||||
import { IconPlus } from '@/ui/icon';
|
||||
import { useTrackPointer } from '@/ui/utilities/pointer-event/hooks/useTrackPointer';
|
||||
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
|
||||
import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue';
|
||||
|
||||
import { useTableColumns } from '../hooks/useTableColumns';
|
||||
import { TableRecoilScopeContext } from '../states/recoil-scope-contexts/TableRecoilScopeContext';
|
||||
import { resizeFieldOffsetState } from '../states/resizeFieldOffsetState';
|
||||
import { hiddenTableColumnsScopedSelector } from '../states/selectors/hiddenTableColumnsScopedSelector';
|
||||
import { tableColumnsByKeyScopedSelector } from '../states/selectors/tableColumnsByKeyScopedSelector';
|
||||
import { visibleTableColumnsScopedSelector } from '../states/selectors/visibleTableColumnsScopedSelector';
|
||||
import { tableColumnsScopedState } from '../states/tableColumnsScopedState';
|
||||
|
||||
import { ColumnHead } from './ColumnHead';
|
||||
import { EntityTableColumnMenu } from './EntityTableColumnMenu';
|
||||
import { SelectAllCheckbox } from './SelectAllCheckbox';
|
||||
|
||||
const COLUMN_MIN_WIDTH = 75;
|
||||
|
||||
const StyledColumnHeaderCell = styled.th<{
|
||||
columnWidth: number;
|
||||
isResizing?: boolean;
|
||||
}>`
|
||||
${({ columnWidth }) => `
|
||||
min-width: ${columnWidth}px;
|
||||
width: ${columnWidth}px;
|
||||
`}
|
||||
position: relative;
|
||||
user-select: none;
|
||||
${({ isResizing, theme }) => {
|
||||
if (isResizing) {
|
||||
return `&:after {
|
||||
background-color: ${theme.color.blue};
|
||||
bottom: 0;
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
right: -1px;
|
||||
top: 0;
|
||||
width: 2px;
|
||||
}`;
|
||||
}
|
||||
}};
|
||||
`;
|
||||
|
||||
const StyledResizeHandler = styled.div`
|
||||
bottom: 0;
|
||||
cursor: col-resize;
|
||||
padding: 0 ${({ theme }) => theme.spacing(2)};
|
||||
position: absolute;
|
||||
right: -9px;
|
||||
top: 0;
|
||||
width: 3px;
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
const StyledAddIconButtonWrapper = styled.div`
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const StyledEntityTableColumnMenu = styled(EntityTableColumnMenu)`
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
z-index: ${({ theme }) => theme.lastLayerZIndex};
|
||||
`;
|
||||
|
||||
const StyledTableHead = styled.thead`
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
export const EntityTableHeader = () => {
|
||||
const [resizeFieldOffset, setResizeFieldOffset] = useRecoilState(
|
||||
resizeFieldOffsetState,
|
||||
);
|
||||
const tableColumns = useRecoilScopedValue(
|
||||
tableColumnsScopedState,
|
||||
TableRecoilScopeContext,
|
||||
);
|
||||
const tableColumnsByKey = useRecoilScopedValue(
|
||||
tableColumnsByKeyScopedSelector,
|
||||
TableRecoilScopeContext,
|
||||
);
|
||||
const hiddenTableColumns = useRecoilScopedValue(
|
||||
hiddenTableColumnsScopedSelector,
|
||||
TableRecoilScopeContext,
|
||||
);
|
||||
const visibleTableColumns = useRecoilScopedValue(
|
||||
visibleTableColumnsScopedSelector,
|
||||
TableRecoilScopeContext,
|
||||
);
|
||||
|
||||
const [initialPointerPositionX, setInitialPointerPositionX] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [resizedFieldKey, setResizedFieldKey] = useState<string | null>(null);
|
||||
const [isColumnMenuOpen, setIsColumnMenuOpen] = useState(false);
|
||||
|
||||
const { handleColumnsChange } = useTableColumns();
|
||||
|
||||
const handleResizeHandlerStart = useCallback((positionX: number) => {
|
||||
setInitialPointerPositionX(positionX);
|
||||
}, []);
|
||||
|
||||
const handleResizeHandlerMove = useCallback(
|
||||
(positionX: number) => {
|
||||
if (!initialPointerPositionX) return;
|
||||
setResizeFieldOffset(positionX - initialPointerPositionX);
|
||||
},
|
||||
[setResizeFieldOffset, initialPointerPositionX],
|
||||
);
|
||||
|
||||
const handleResizeHandlerEnd = useRecoilCallback(
|
||||
({ snapshot, set }) =>
|
||||
async () => {
|
||||
if (!resizedFieldKey) return;
|
||||
|
||||
const nextWidth = Math.round(
|
||||
Math.max(
|
||||
tableColumnsByKey[resizedFieldKey].size +
|
||||
snapshot.getLoadable(resizeFieldOffsetState).valueOrThrow(),
|
||||
COLUMN_MIN_WIDTH,
|
||||
),
|
||||
);
|
||||
|
||||
set(resizeFieldOffsetState, 0);
|
||||
setInitialPointerPositionX(null);
|
||||
setResizedFieldKey(null);
|
||||
|
||||
if (nextWidth !== tableColumnsByKey[resizedFieldKey].size) {
|
||||
const nextColumns = tableColumns.map((column) =>
|
||||
column.key === resizedFieldKey
|
||||
? { ...column, size: nextWidth }
|
||||
: column,
|
||||
);
|
||||
|
||||
await handleColumnsChange(nextColumns);
|
||||
}
|
||||
},
|
||||
[resizedFieldKey, tableColumnsByKey, tableColumns, handleColumnsChange],
|
||||
);
|
||||
|
||||
useTrackPointer({
|
||||
shouldTrackPointer: resizedFieldKey !== null,
|
||||
onMouseDown: handleResizeHandlerStart,
|
||||
onMouseMove: handleResizeHandlerMove,
|
||||
onMouseUp: handleResizeHandlerEnd,
|
||||
});
|
||||
|
||||
const toggleColumnMenu = useCallback(() => {
|
||||
setIsColumnMenuOpen((previousValue) => !previousValue);
|
||||
}, []);
|
||||
|
||||
const primaryColumn = visibleTableColumns[0];
|
||||
|
||||
return (
|
||||
<StyledTableHead data-select-disable>
|
||||
<tr>
|
||||
<th
|
||||
style={{
|
||||
width: 30,
|
||||
minWidth: 30,
|
||||
maxWidth: 30,
|
||||
}}
|
||||
>
|
||||
<SelectAllCheckbox />
|
||||
</th>
|
||||
<RecoilScope CustomRecoilScopeContext={DropdownRecoilScopeContext}>
|
||||
{visibleTableColumns.map((column, index) => (
|
||||
<StyledColumnHeaderCell
|
||||
key={column.key}
|
||||
isResizing={resizedFieldKey === column.key}
|
||||
columnWidth={Math.max(
|
||||
tableColumnsByKey[column.key].size +
|
||||
(resizedFieldKey === column.key ? resizeFieldOffset : 0),
|
||||
COLUMN_MIN_WIDTH,
|
||||
)}
|
||||
>
|
||||
<ColumnHead
|
||||
column={column}
|
||||
isFirstColumn={index === 1}
|
||||
isLastColumn={index === visibleTableColumns.length - 1}
|
||||
primaryColumnKey={primaryColumn.key}
|
||||
/>
|
||||
|
||||
<StyledResizeHandler
|
||||
className="cursor-col-resize"
|
||||
role="separator"
|
||||
onPointerDown={() => {
|
||||
setResizedFieldKey(column.key);
|
||||
}}
|
||||
/>
|
||||
</StyledColumnHeaderCell>
|
||||
))}
|
||||
</RecoilScope>
|
||||
|
||||
<th>
|
||||
{hiddenTableColumns.length > 0 && (
|
||||
<StyledAddIconButtonWrapper>
|
||||
<IconButton
|
||||
size="medium"
|
||||
variant="tertiary"
|
||||
Icon={IconPlus}
|
||||
onClick={toggleColumnMenu}
|
||||
position="middle"
|
||||
/>
|
||||
{isColumnMenuOpen && (
|
||||
<StyledEntityTableColumnMenu
|
||||
onAddColumn={toggleColumnMenu}
|
||||
onClickOutside={toggleColumnMenu}
|
||||
/>
|
||||
)}
|
||||
</StyledAddIconButtonWrapper>
|
||||
)}
|
||||
</th>
|
||||
</tr>
|
||||
</StyledTableHead>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,39 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { DropdownButton } from '@/ui/dropdown/components/DropdownButton';
|
||||
|
||||
import {
|
||||
EntityTableHeaderOptionsProps,
|
||||
TableColumnDropdownMenu,
|
||||
} from './TableColumnDropdownMenu';
|
||||
|
||||
const StyledDropdownContainer = styled.div`
|
||||
left: 0px;
|
||||
position: absolute;
|
||||
top: 32px;
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
export const EntityTableHeaderOptions = ({
|
||||
isFirstColumn,
|
||||
isLastColumn,
|
||||
primaryColumnKey,
|
||||
column,
|
||||
}: EntityTableHeaderOptionsProps) => {
|
||||
return (
|
||||
<StyledDropdownContainer>
|
||||
<DropdownButton
|
||||
dropdownId={column.key + '-header'}
|
||||
dropdownComponents={
|
||||
<TableColumnDropdownMenu
|
||||
column={column}
|
||||
isFirstColumn={isFirstColumn}
|
||||
isLastColumn={isLastColumn}
|
||||
primaryColumnKey={primaryColumnKey}
|
||||
/>
|
||||
}
|
||||
dropdownHotkeyScope={{ scope: column.key + '-header' }}
|
||||
/>
|
||||
</StyledDropdownContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,53 @@
|
||||
import { forwardRef } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue';
|
||||
|
||||
import { ColumnContext } from '../contexts/ColumnContext';
|
||||
import { useCurrentRowSelected } from '../hooks/useCurrentRowSelected';
|
||||
import { TableRecoilScopeContext } from '../states/recoil-scope-contexts/TableRecoilScopeContext';
|
||||
import { visibleTableColumnsScopedSelector } from '../states/selectors/visibleTableColumnsScopedSelector';
|
||||
|
||||
import { CheckboxCell } from './CheckboxCell';
|
||||
import { EntityTableCell } from './EntityTableCell';
|
||||
|
||||
const StyledRow = styled.tr<{ selected: boolean }>`
|
||||
background: ${(props) =>
|
||||
props.selected ? props.theme.accent.quaternary : 'none'};
|
||||
`;
|
||||
|
||||
type EntityTableRowProps = {
|
||||
rowId: string;
|
||||
};
|
||||
|
||||
export const EntityTableRow = forwardRef<
|
||||
HTMLTableRowElement,
|
||||
EntityTableRowProps
|
||||
>(({ rowId }, ref) => {
|
||||
const visibleTableColumns = useRecoilScopedValue(
|
||||
visibleTableColumnsScopedSelector,
|
||||
TableRecoilScopeContext,
|
||||
);
|
||||
const { currentRowSelected } = useCurrentRowSelected();
|
||||
|
||||
return (
|
||||
<StyledRow
|
||||
ref={ref}
|
||||
data-testid={`row-id-${rowId}`}
|
||||
selected={currentRowSelected}
|
||||
data-selectable-id={rowId}
|
||||
>
|
||||
<td>
|
||||
<CheckboxCell />
|
||||
</td>
|
||||
{visibleTableColumns.map((column, columnIndex) => {
|
||||
return (
|
||||
<ColumnContext.Provider value={column} key={column.key}>
|
||||
<EntityTableCell cellIndex={columnIndex} />
|
||||
</ColumnContext.Provider>
|
||||
);
|
||||
})}
|
||||
<td></td>
|
||||
</StyledRow>
|
||||
);
|
||||
});
|
||||
@ -0,0 +1,35 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { Checkbox } from '@/ui/input/components/Checkbox';
|
||||
|
||||
import { useSelectAllRows } from '../hooks/useSelectAllRows';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: center;
|
||||
|
||||
display: flex;
|
||||
height: 32px;
|
||||
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
export const SelectAllCheckbox = () => {
|
||||
const { selectAllRows, allRowsSelectedStatus } = useSelectAllRows();
|
||||
|
||||
const checked = allRowsSelectedStatus === 'all';
|
||||
const indeterminate = allRowsSelectedStatus === 'some';
|
||||
|
||||
const onChange = () => {
|
||||
selectAllRows();
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
indeterminate={indeterminate}
|
||||
/>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,79 @@
|
||||
import { StyledDropdownMenu } from '@/ui/dropdown/components/StyledDropdownMenu';
|
||||
import { StyledDropdownMenuItemsContainer } from '@/ui/dropdown/components/StyledDropdownMenuItemsContainer';
|
||||
import { useDropdownButton } from '@/ui/dropdown/hooks/useDropdownButton';
|
||||
import { FieldMetadata } from '@/ui/field/types/FieldMetadata';
|
||||
import { IconArrowLeft, IconArrowRight, IconEyeOff } from '@/ui/icon';
|
||||
import { MenuItem } from '@/ui/menu-item/components/MenuItem';
|
||||
|
||||
import { ColumnHeadDropdownId } from '../constants/ColumnHeadDropdownId';
|
||||
import { useTableColumns } from '../hooks/useTableColumns';
|
||||
import { ColumnDefinition } from '../types/ColumnDefinition';
|
||||
|
||||
export type EntityTableHeaderOptionsProps = {
|
||||
column: ColumnDefinition<FieldMetadata>;
|
||||
isFirstColumn: boolean;
|
||||
isLastColumn: boolean;
|
||||
primaryColumnKey: string;
|
||||
};
|
||||
|
||||
export const TableColumnDropdownMenu = ({
|
||||
column,
|
||||
isFirstColumn,
|
||||
isLastColumn,
|
||||
primaryColumnKey,
|
||||
}: EntityTableHeaderOptionsProps) => {
|
||||
const { handleColumnVisibilityChange, handleMoveTableColumn } =
|
||||
useTableColumns();
|
||||
|
||||
const { closeDropdownButton } = useDropdownButton({
|
||||
dropdownId: ColumnHeadDropdownId,
|
||||
});
|
||||
|
||||
const handleColumnMoveLeft = () => {
|
||||
closeDropdownButton();
|
||||
if (isFirstColumn) {
|
||||
return;
|
||||
}
|
||||
handleMoveTableColumn('left', column);
|
||||
};
|
||||
|
||||
const handleColumnMoveRight = () => {
|
||||
closeDropdownButton();
|
||||
if (isLastColumn) {
|
||||
return;
|
||||
}
|
||||
handleMoveTableColumn('right', column);
|
||||
};
|
||||
|
||||
const handleColumnVisibility = () => {
|
||||
handleColumnVisibilityChange(column);
|
||||
};
|
||||
|
||||
return column.key === primaryColumnKey ? (
|
||||
<></>
|
||||
) : (
|
||||
<StyledDropdownMenu>
|
||||
<StyledDropdownMenuItemsContainer>
|
||||
{!isFirstColumn && (
|
||||
<MenuItem
|
||||
LeftIcon={IconArrowLeft}
|
||||
onClick={handleColumnMoveLeft}
|
||||
text="Move left"
|
||||
/>
|
||||
)}
|
||||
{!isLastColumn && (
|
||||
<MenuItem
|
||||
LeftIcon={IconArrowRight}
|
||||
onClick={handleColumnMoveRight}
|
||||
text="Move right"
|
||||
/>
|
||||
)}
|
||||
<MenuItem
|
||||
LeftIcon={IconEyeOff}
|
||||
onClick={handleColumnVisibility}
|
||||
text="Hide"
|
||||
/>
|
||||
</StyledDropdownMenuItemsContainer>
|
||||
</StyledDropdownMenu>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user