Files
twenty/front/src/modules/ui/table/components/EntityTableHeader.tsx
T
ThaïsandGitHub 417ca3d131 feat: add table columns (#1056)
* feat: add table columns

Closes #879

* refactor: ComponentProps first
2023-08-04 10:07:31 -07:00

247 lines
7.0 KiB
TypeScript

import { useCallback, useState } from 'react';
import { getOperationName } from '@apollo/client/utilities';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useRecoilCallback, useRecoilState, useRecoilValue } from 'recoil';
import { IconButton } from '@/ui/button/components/IconButton';
import { IconPlus } from '@/ui/icon';
import { useTrackPointer } from '@/ui/utilities/pointer-event/hooks/useTrackPointer';
import { GET_VIEW_FIELDS } from '@/views/queries/select';
import {
useCreateViewFieldMutation,
useUpdateViewFieldMutation,
} from '~/generated/graphql';
import { toViewFieldInput } from '../hooks/useLoadView';
import { resizeFieldOffsetState } from '../states/resizeFieldOffsetState';
import {
addableViewFieldDefinitionsState,
columnWidthByViewFieldIdState,
viewFieldsState,
} from '../states/viewFieldsState';
import type {
ViewFieldDefinition,
ViewFieldMetadata,
} from '../types/ViewField';
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 StyledAddIconButton = styled(IconButton)`
border-radius: 0;
`;
const StyledEntityTableColumnMenu = styled(EntityTableColumnMenu)`
position: absolute;
right: 0;
top: 100%;
z-index: ${({ theme }) => theme.lastLayerZIndex};
`;
export function EntityTableHeader() {
const theme = useTheme();
const [{ objectName, viewFields }, setViewFieldsState] =
useRecoilState(viewFieldsState);
const columnWidths = useRecoilValue(columnWidthByViewFieldIdState);
const addableViewFieldDefinitions = useRecoilValue(
addableViewFieldDefinitionsState,
);
const [offset, setOffset] = useRecoilState(resizeFieldOffsetState);
const [initialPointerPositionX, setInitialPointerPositionX] = useState<
number | null
>(null);
const [resizedFieldId, setResizedFieldId] = useState<string | null>(null);
const [isColumnMenuOpen, setIsColumnMenuOpen] = useState(false);
const [createViewFieldMutation] = useCreateViewFieldMutation();
const [updateViewFieldMutation] = useUpdateViewFieldMutation();
const handleResizeHandlerStart = useCallback((positionX: number) => {
setInitialPointerPositionX(positionX);
}, []);
const handleResizeHandlerMove = useCallback(
(positionX: number) => {
if (!initialPointerPositionX) return;
setOffset(positionX - initialPointerPositionX);
},
[setOffset, initialPointerPositionX],
);
const handleResizeHandlerEnd = useRecoilCallback(
({ snapshot, set }) =>
() => {
if (!resizedFieldId) return;
const nextWidth = Math.round(
Math.max(
columnWidths[resizedFieldId] +
snapshot.getLoadable(resizeFieldOffsetState).valueOrThrow(),
COLUMN_MIN_WIDTH,
),
);
if (nextWidth !== columnWidths[resizedFieldId]) {
// Optimistic update to avoid "bouncing width" visual effect on resize.
setViewFieldsState((previousState) => ({
...previousState,
viewFields: previousState.viewFields.map((viewField) =>
viewField.id === resizedFieldId
? { ...viewField, columnSize: nextWidth }
: viewField,
),
}));
updateViewFieldMutation({
variables: {
data: { sizeInPx: nextWidth },
where: { id: resizedFieldId },
},
refetchQueries: [getOperationName(GET_VIEW_FIELDS) ?? ''],
});
}
set(resizeFieldOffsetState, 0);
setInitialPointerPositionX(null);
setResizedFieldId(null);
},
[resizedFieldId, columnWidths, setResizedFieldId],
);
useTrackPointer({
shouldTrackPointer: resizedFieldId !== null,
onMouseDown: handleResizeHandlerStart,
onMouseMove: handleResizeHandlerMove,
onMouseUp: handleResizeHandlerEnd,
});
const toggleColumnMenu = useCallback(() => {
setIsColumnMenuOpen((previousValue) => !previousValue);
}, []);
const handleAddViewField = useCallback(
(viewFieldDefinition: ViewFieldDefinition<ViewFieldMetadata>) => {
setIsColumnMenuOpen(false);
if (!objectName) return;
createViewFieldMutation({
variables: {
data: toViewFieldInput(objectName, {
...viewFieldDefinition,
columnOrder: viewFields.length + 1,
}),
},
refetchQueries: [getOperationName(GET_VIEW_FIELDS) ?? ''],
});
},
[createViewFieldMutation, objectName, viewFields.length],
);
return (
<thead>
<tr>
<th
style={{
width: 30,
minWidth: 30,
maxWidth: 30,
}}
>
<SelectAllCheckbox />
</th>
{viewFields.map((viewField) => (
<StyledColumnHeaderCell
key={viewField.id}
isResizing={resizedFieldId === viewField.id}
columnWidth={Math.max(
columnWidths[viewField.id] +
(resizedFieldId === viewField.id ? offset : 0),
COLUMN_MIN_WIDTH,
)}
>
<ColumnHead
viewName={viewField.columnLabel}
viewIcon={viewField.columnIcon}
/>
<StyledResizeHandler
className="cursor-col-resize"
role="separator"
onPointerDown={() => {
setResizedFieldId(viewField.id);
}}
/>
</StyledColumnHeaderCell>
))}
<th>
{addableViewFieldDefinitions.length > 0 && (
<StyledAddIconButtonWrapper>
<StyledAddIconButton
size="large"
icon={<IconPlus size={theme.icon.size.md} />}
onClick={toggleColumnMenu}
/>
{isColumnMenuOpen && (
<StyledEntityTableColumnMenu
onAddViewField={handleAddViewField}
onClickOutside={toggleColumnMenu}
viewFieldDefinitions={addableViewFieldDefinitions}
/>
)}
</StyledAddIconButtonWrapper>
)}
</th>
</tr>
</thead>
);
}