Refactor drag selection: Replace external library with custom implementation and add auto-scroll (#12134)

Closes #12076
Closes #11764

Replaced the `@air/react-drag-to-select` library with a custom
implementation to get better control over the selection behavior and add
auto-scroll functionality.

**What changed:**
- Removed external drag selection dependency 
- Built custom drag selection from scratch using pointer events --
@charlesBochet
- Added auto-scroll when dragging near container edges
- Fixed boundary detection so selection stays within intended areas
- Added proper `data-select-disable` support for checkboxes and other
non-selectable elements

The new implementation gives us full control over the selection logic
and eliminates the external dependency while adding the auto-scroll
feature that was **not** requested 😂

**Auto Scroll**



https://github.com/user-attachments/assets/3509966d-5b6e-4f6c-a77a-f9a2bf26049f



related to #12076 


https://github.com/user-attachments/assets/2837f80e-728c-4739-a0e2-b8d7bc83a21a

**Also fixed:**
- Record board column height not extending to the bottom (styling issue
I found while working on this)

before:

<img width="1512" alt="Screenshot 2025-05-19 at 23 58 54"
src="https://github.com/user-attachments/assets/602b310f-7ef6-44f6-99e9-da5ff59b31d3"
/>

after:

<img width="1512" alt="Screenshot 2025-05-19 at 23 56 40"
src="https://github.com/user-attachments/assets/1d0ecb5c-49e0-4f03-be3b-154a6f16a7a4"
/>

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
nitin
2025-05-26 15:28:22 +05:30
committed by GitHub
parent 621a779526
commit 524a1d78d2
24 changed files with 1244 additions and 100 deletions

View File

@ -1,7 +1,6 @@
{
"private": true,
"dependencies": {
"@air/react-drag-to-select": "^5.0.8",
"@apollo/client": "^3.7.17",
"@apollo/server": "^4.7.3",
"@aws-sdk/client-lambda": "^3.614.0",

View File

@ -31,6 +31,7 @@ import { MODAL_BACKDROP_CLICK_OUTSIDE_ID } from '@/ui/layout/modal/constants/Mod
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { PAGE_ACTION_CONTAINER_CLICK_OUTSIDE_ID } from '@/ui/layout/page/constants/PageActionContainerClickOutsideId';
import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect';
import { RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS } from '@/ui/utilities/drag-select/constants/RecordIndecDragSelectBoundaryClass';
import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
@ -45,10 +46,13 @@ const StyledContainer = styled.div`
display: flex;
flex: 1;
flex-direction: row;
min-height: 100%;
position: relative;
`;
const StyledColumnContainer = styled.div`
display: flex;
& > *:not(:first-of-type) {
border-left: 1px solid ${({ theme }) => theme.border.color.light};
}
@ -57,12 +61,14 @@ const StyledColumnContainer = styled.div`
const StyledContainerContainer = styled.div`
display: flex;
flex-direction: column;
min-height: calc(100% - ${({ theme }) => theme.spacing(2)});
height: min-content;
`;
const StyledBoardContentContainer = styled.div`
display: flex;
flex-direction: column;
height: calc(100% - 48px);
flex: 1;
`;
export const RecordBoard = () => {
@ -233,13 +239,18 @@ export const RecordBoard = () => {
))}
</StyledColumnContainer>
</DragDropContext>
<DragSelect
selectableItemsContainerRef={boardRef}
onDragSelectionEnd={handleDragSelectionEnd}
onDragSelectionChange={setRecordAsSelected}
onDragSelectionStart={handleDragSelectionStart}
scrollWrapperComponentInstanceId={`scroll-wrapper-record-board-${recordBoardId}`}
selectionBoundaryClass={
RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS
}
/>
</StyledContainer>
<DragSelect
dragSelectable={boardRef}
onDragSelectionEnd={handleDragSelectionEnd}
onDragSelectionChange={setRecordAsSelected}
onDragSelectionStart={handleDragSelectionStart}
/>
</StyledBoardContentContainer>
</StyledContainerContainer>
</ScrollWrapper>

View File

@ -14,6 +14,8 @@ const StyledColumn = styled.div`
flex-direction: column;
max-width: 200px;
min-width: 200px;
min-height: 100%;
flex: 1;
padding: ${({ theme }) => theme.spacing(2)};
padding-top: 0px;
position: relative;

View File

@ -16,6 +16,7 @@ import { useHandleIndexIdentifierClick } from '@/object-record/record-index/hook
import { RecordSortsComponentInstanceContext } from '@/object-record/record-sort/states/context/RecordSortsComponentInstanceContext';
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { PageBody } from '@/ui/layout/page/components/PageBody';
import { RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS } from '@/ui/utilities/drag-select/constants/RecordIndecDragSelectBoundaryClass';
import { PageTitle } from '@/ui/utilities/page-title/components/PageTitle';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
@ -90,7 +91,9 @@ export const RecordIndexContainerGater = () => {
/>
<RecordIndexPageHeader />
<PageBody>
<StyledIndexContainer>
<StyledIndexContainer
className={RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS}
>
<RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect />
<RecordIndexContainer />
</StyledIndexContainer>

View File

@ -76,6 +76,7 @@ export const RecordTable = () => {
handleDragSelectionEnd={handleDragSelectionEnd}
setRowSelected={setRowSelected}
hasRecordGroups={hasRecordGroups}
recordTableId={recordTableId}
/>
)}
</>

View File

@ -5,8 +5,9 @@ import { RecordTableNoRecordGroupBody } from '@/object-record/record-table/recor
import { RecordTableRecordGroupsBody } from '@/object-record/record-table/record-table-body/components/RecordTableRecordGroupsBody';
import { RecordTableHeader } from '@/object-record/record-table/record-table-header/components/RecordTableHeader';
import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect';
import { RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS } from '@/ui/utilities/drag-select/constants/RecordIndecDragSelectBoundaryClass';
import styled from '@emotion/styled';
import { useState } from 'react';
import { useRef, useState } from 'react';
const StyledTableWithPointerEvents = styled(StyledTable)<{
isDragging: boolean;
@ -16,12 +17,20 @@ const StyledTableWithPointerEvents = styled(StyledTable)<{
}
`;
const StyledTableContainer = styled.div`
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
`;
export interface RecordTableContentProps {
tableBodyRef: React.RefObject<HTMLTableElement>;
handleDragSelectionStart: () => void;
handleDragSelectionEnd: () => void;
setRowSelected: (rowId: string, selected: boolean) => void;
hasRecordGroups: boolean;
recordTableId: string;
}
export const RecordTableContent = ({
@ -30,8 +39,10 @@ export const RecordTableContent = ({
handleDragSelectionEnd,
setRowSelected,
hasRecordGroups,
recordTableId,
}: RecordTableContentProps) => {
const [isDragging, setIsDragging] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const handleDragStart = () => {
setIsDragging(true);
@ -44,7 +55,7 @@ export const RecordTableContent = ({
};
return (
<>
<StyledTableContainer ref={containerRef}>
<StyledTableWithPointerEvents ref={tableBodyRef} isDragging={isDragging}>
<RecordTableHeader />
{hasRecordGroups ? (
@ -56,11 +67,13 @@ export const RecordTableContent = ({
<RecordTableStickyBottomEffect />
</StyledTableWithPointerEvents>
<DragSelect
dragSelectable={tableBodyRef}
selectableItemsContainerRef={containerRef}
onDragSelectionStart={handleDragStart}
onDragSelectionChange={setRowSelected}
onDragSelectionEnd={handleDragEnd}
scrollWrapperComponentInstanceId={`record-table-scroll-${recordTableId}`}
selectionBoundaryClass={RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS}
/>
</>
</StyledTableContainer>
);
};

View File

@ -32,7 +32,7 @@ export const RecordTableCellCheckbox = () => {
return (
<StyledRecordTableTd isSelected={isSelected} hasRightBorder={false}>
<StyledContainer onClick={handleClick}>
<StyledContainer onClick={handleClick} data-select-disable>
<Checkbox hoverable checked={isSelected} />
</StyledContainer>
</StyledRecordTableTd>

View File

@ -16,6 +16,7 @@ import { tableColumnsComponentState } from '@/object-record/record-table/states/
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission';
import { useTrackPointer } from '@/ui/utilities/pointer-event/hooks/useTrackPointer';
import { PointerEventListener } from '@/ui/utilities/pointer-event/types/PointerEventListener';
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
@ -138,16 +139,19 @@ export const RecordTableHeaderCell = ({
const { handleColumnsChange } = useTableColumns();
const handleResizeHandlerStart = useCallback((positionX: number) => {
setInitialPointerPositionX(positionX);
}, []);
const handleResizeHandlerStart = useCallback<PointerEventListener>(
({ x }) => {
setInitialPointerPositionX(x);
},
[],
);
const [iconVisibility, setIconVisibility] = useState(false);
const handleResizeHandlerMove = useCallback(
(positionX: number) => {
const handleResizeHandlerMove = useCallback<PointerEventListener>(
({ x }) => {
if (!initialPointerPositionX) return;
setResizeFieldOffset(positionX - initialPointerPositionX);
setResizeFieldOffset(x - initialPointerPositionX);
},
[setResizeFieldOffset, initialPointerPositionX],
);

View File

@ -82,7 +82,7 @@ export const RecordTableHeaderCheckboxColumn = () => {
<StyledColumnHeaderCell
isFirstRowActiveOrFocused={isFirstRowActiveOrFocused}
>
<StyledContainer>
<StyledContainer data-select-disable>
<Checkbox
hoverable
checked={checked}

View File

@ -1,76 +1,250 @@
import {
boxesIntersect,
useSelectionContainer,
} from '@air/react-drag-to-select';
import { useTheme } from '@emotion/react';
import { RefObject } from 'react';
import styled from '@emotion/styled';
import { RefObject, useCallback, useState } from 'react';
import { useDragSelectWithAutoScroll } from '@/ui/utilities/drag-select/hooks/useDragSelectWithAutoScroll';
import { useTrackPointer } from '@/ui/utilities/pointer-event/hooks/useTrackPointer';
import { isDefined } from 'twenty-shared/utils';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { useDragSelect } from '../hooks/useDragSelect';
import { RGBA } from 'twenty-ui/theme';
import { SelectionBox } from '../types/SelectionBox';
import { isValidSelectionStart } from '../utils/selectionBoxValidation';
type DragSelectProps = {
dragSelectable: RefObject<HTMLElement>;
selectableItemsContainerRef: RefObject<HTMLElement>;
onDragSelectionChange: (id: string, selected: boolean) => void;
onDragSelectionStart?: (event: MouseEvent) => void;
onDragSelectionEnd?: (event: MouseEvent) => void;
onDragSelectionStart?: (event: MouseEvent | TouchEvent) => void;
onDragSelectionEnd?: (event: MouseEvent | TouchEvent) => void;
scrollWrapperComponentInstanceId?: string;
selectionBoundaryClass?: string;
};
type Position = {
x: number;
y: number;
};
const StyledDragSelection = styled.div<SelectionBox>`
position: absolute;
z-index: 99;
opacity: 0.2;
border: 1px solid ${({ theme }) => theme.color.blue10};
background: ${({ theme }) => theme.color.blue30};
top: ${({ top }) => top}px;
left: ${({ left }) => left}px;
width: ${({ width }) => width}px;
height: ${({ height }) => height}px;
`;
export const DragSelect = ({
dragSelectable,
selectableItemsContainerRef,
onDragSelectionChange,
onDragSelectionStart,
onDragSelectionEnd,
scrollWrapperComponentInstanceId,
selectionBoundaryClass,
}: DragSelectProps) => {
const theme = useTheme();
const { isDragSelectionStartEnabled } = useDragSelect();
const { DragSelection } = useSelectionContainer({
shouldStartSelecting: (target) => {
const [isDragging, setIsDragging] = useState(false);
const [isSelecting, setIsSelecting] = useState(false);
const boxesIntersect = useCallback(
(boxA: SelectionBox, boxB: SelectionBox) =>
boxA.left <= boxB.left + boxB.width &&
boxA.left + boxA.width >= boxB.left &&
boxA.top <= boxB.top + boxB.height &&
boxA.top + boxA.height >= boxB.top,
[],
);
const { handleAutoScroll } = useDragSelectWithAutoScroll({
scrollWrapperComponentInstanceId,
});
const [startPoint, setStartPoint] = useState<Position | null>(null);
const [endPoint, setEndPoint] = useState<Position | null>(null);
const [selectionBox, setSelectionBox] = useState<SelectionBox | null>(null);
const getPositionRelativeToContainer = useCallback(
(x: number, y: number) => {
const containerRect =
selectableItemsContainerRef.current?.getBoundingClientRect();
if (!containerRect) {
return { x, y };
}
return { x: x - containerRect.left, y: y - containerRect.top };
},
[selectableItemsContainerRef],
);
useTrackPointer({
onMouseDown: ({ x, y, event }) => {
const { x: relativeX, y: relativeY } = getPositionRelativeToContainer(
x,
y,
);
if (shouldStartSelecting(event.target)) {
setIsDragging(true);
setIsSelecting(false);
setStartPoint({
x: relativeX,
y: relativeY,
});
setEndPoint({
x: relativeX,
y: relativeY,
});
setSelectionBox({
top: relativeY,
left: relativeX,
width: 0,
height: 0,
});
}
event.preventDefault();
},
onMouseMove: ({ x, y, event }) => {
if (isDragging) {
const { x: relativeX, y: relativeY } = getPositionRelativeToContainer(
x,
y,
);
if (
!isDefined(startPoint) ||
!isDefined(endPoint) ||
!isDefined(selectionBox)
) {
return;
}
const newEndPoint = { ...endPoint };
newEndPoint.x = relativeX;
newEndPoint.y = relativeY;
if (!isDeeplyEqual(newEndPoint, endPoint)) {
setEndPoint(newEndPoint);
const newSelectionBox = {
top: Math.min(startPoint.y, newEndPoint.y),
left: Math.min(startPoint.x, newEndPoint.x),
width: Math.abs(newEndPoint.x - startPoint.x),
height: Math.abs(newEndPoint.y - startPoint.y),
};
if (isValidSelectionStart(newSelectionBox)) {
if (!isSelecting) {
setIsSelecting(true);
onDragSelectionStart?.(event);
}
setSelectionBox(newSelectionBox);
} else if (isSelecting) {
setSelectionBox(newSelectionBox);
}
}
if (isSelecting && isDefined(selectionBox)) {
const scrollAwareBox = {
...selectionBox,
top: selectionBox.top + window.scrollY,
left: selectionBox.left + window.scrollX,
};
Array.from(
selectableItemsContainerRef.current?.querySelectorAll(
'[data-selectable-id]',
) ?? [],
).forEach((item) => {
const id = item.getAttribute('data-selectable-id');
if (!isDefined(id)) {
return;
}
const itemBox = item.getBoundingClientRect();
const { x: boxX, y: boxY } = getPositionRelativeToContainer(
itemBox.left,
itemBox.top,
);
if (
boxesIntersect(scrollAwareBox, {
width: itemBox.width,
height: itemBox.height,
top: boxY,
left: boxX,
})
) {
onDragSelectionChange(id, true);
} else {
onDragSelectionChange(id, false);
}
});
}
handleAutoScroll(x, y);
}
},
onMouseUp: ({ event }) => {
if (isSelecting) {
onDragSelectionEnd?.(event);
}
setIsDragging(false);
setIsSelecting(false);
},
});
const shouldStartSelecting = useCallback(
(target: EventTarget | null) => {
if (!isDragSelectionStartEnabled()) {
return false;
}
if (!(target instanceof Node)) {
return false;
}
const selectionBoundaryElement = selectionBoundaryClass
? (selectableItemsContainerRef.current?.closest(
`.${selectionBoundaryClass}`,
) ?? selectableItemsContainerRef.current)
: selectableItemsContainerRef.current;
if (!selectionBoundaryElement?.contains(target)) {
return false;
}
if (target instanceof HTMLElement || target instanceof SVGElement) {
let el = target;
while (el.parentElement && !el.dataset.selectDisable) {
el = el.parentElement;
}
return el.dataset.selectDisable !== 'true';
if (el.dataset.selectDisable === 'true') {
return false;
}
}
return true;
},
onSelectionStart: onDragSelectionStart,
onSelectionEnd: onDragSelectionEnd,
onSelectionChange: (box) => {
const scrollAwareBox = {
...box,
top: box.top + window.scrollY,
left: box.left + window.scrollX,
};
Array.from(
dragSelectable.current?.querySelectorAll('[data-selectable-id]') ?? [],
).forEach((item) => {
const id = item.getAttribute('data-selectable-id');
if (!id) {
return;
}
if (boxesIntersect(scrollAwareBox, item.getBoundingClientRect())) {
onDragSelectionChange(id, true);
} else {
onDragSelectionChange(id, false);
}
});
},
selectionProps: {
style: {
border: `1px solid ${theme.color.blue10}`,
background: RGBA(theme.color.blue30, 0.4),
position: `absolute`,
zIndex: 99,
},
},
});
[
isDragSelectionStartEnabled,
selectableItemsContainerRef,
selectionBoundaryClass,
],
);
return <DragSelection />;
return (
isDragging &&
isSelecting &&
isDefined(selectionBox) && (
<StyledDragSelection
top={selectionBox.top}
left={selectionBox.left}
width={selectionBox.width}
height={selectionBox.height}
/>
)
);
};

View File

@ -0,0 +1,270 @@
import styled from '@emotion/styled';
import { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/test';
import { useRef, useState } from 'react';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { isDefined } from 'twenty-shared/utils';
import { ComponentDecorator } from 'twenty-ui/testing';
import { DragSelect } from '../DragSelect';
const StyledContainer = styled.div`
border: 1px solid ${({ theme }) => theme.border.color.light};
border-radius: ${({ theme }) => theme.border.radius.md};
height: 400px;
overflow: hidden;
padding: ${({ theme }) => theme.spacing(4)};
position: relative;
width: 600px;
`;
const StyledSelectableItem = styled.div<{ selected?: boolean }>`
width: 100px;
height: 80px;
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
background: ${({ theme, selected }) =>
selected ? theme.color.blue10 : theme.background.secondary};
display: flex;
align-items: center;
justify-content: center;
margin: ${({ theme }) => theme.spacing(1)};
user-select: none;
cursor: pointer;
&:hover {
background: ${({ theme, selected }) =>
selected ? theme.color.blue20 : theme.background.tertiary};
}
`;
const StyledGrid = styled.div`
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: ${({ theme }) => theme.spacing(2)};
width: 100%;
height: 100%;
`;
const StyledLargeGrid = styled.div`
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(4)};
width: 900px;
height: 600px;
`;
const StyledScrollableWrapper = styled(ScrollWrapper)`
border: 1px solid ${({ theme }) => theme.border.color.light};
border-radius: ${({ theme }) => theme.border.radius.md};
height: 300px;
width: 600px;
`;
type SelectableItemProps = {
id: string;
selected: boolean;
children: React.ReactNode;
};
const SelectableItem = ({ id, selected, children }: SelectableItemProps) => (
<StyledSelectableItem data-selectable-id={id} selected={selected}>
{children}
</StyledSelectableItem>
);
type BasicDragSelectDemoProps = {
itemCount?: number;
disableSelection?: boolean;
};
const BasicDragSelectDemo = ({
itemCount = 12,
disableSelection = false,
}: BasicDragSelectDemoProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const handleSelectionChange = (id: string, selected: boolean) => {
setSelectedItems((prev) => {
const newSet = new Set(prev);
if (selected) {
newSet.add(id);
} else {
newSet.delete(id);
}
return newSet;
});
};
return (
<StyledContainer ref={containerRef}>
<StyledGrid>
{Array.from({ length: itemCount }, (_, index) => (
<SelectableItem
key={index}
id={`item-${index}`}
selected={selectedItems.has(`item-${index}`)}
>
Item {index + 1}
</SelectableItem>
))}
</StyledGrid>
{!disableSelection && (
<DragSelect
selectableItemsContainerRef={containerRef}
onDragSelectionChange={handleSelectionChange}
onDragSelectionStart={() => {}}
onDragSelectionEnd={() => {}}
/>
)}
</StyledContainer>
);
};
const ScrollableDragSelectDemo = () => {
const containerRef = useRef<HTMLDivElement>(null);
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const handleSelectionChange = (id: string, selected: boolean) => {
setSelectedItems((prev) => {
const newSet = new Set(prev);
if (selected) {
newSet.add(id);
} else {
newSet.delete(id);
}
return newSet;
});
};
return (
<StyledScrollableWrapper componentInstanceId="scrollable-demo">
<div ref={containerRef} style={{ position: 'relative', padding: '16px' }}>
<StyledLargeGrid>
{Array.from({ length: 36 }, (_, index) => (
<SelectableItem
key={index}
id={`scroll-item-${index}`}
selected={selectedItems.has(`scroll-item-${index}`)}
>
Item {index + 1}
</SelectableItem>
))}
</StyledLargeGrid>
<DragSelect
selectableItemsContainerRef={containerRef}
onDragSelectionChange={handleSelectionChange}
scrollWrapperComponentInstanceId="scrollable-demo"
/>
</div>
</StyledScrollableWrapper>
);
};
const meta: Meta<typeof DragSelect> = {
title: 'UI/Utilities/DragSelect/DragSelect',
component: DragSelect,
decorators: [ComponentDecorator],
parameters: {
docs: {
description: {
component: `
The DragSelect component enables users to select multiple items by dragging a selection box.
**Key Features:**
- Mouse-based drag selection with visual feedback
- Intersection detection for item selection
- Auto-scroll support for large containers (when used with ScrollWrapper)
- Configurable selection boundaries
- Integration with selectable items via data attributes
- Universal compatibility - works with or without ScrollWrapper
**Usage:**
- Items must have \`data-selectable-id\` attribute
- Use \`data-select-disable="true"\` to disable selection on specific elements
- For auto-scroll functionality, wrap in ScrollWrapper and provide \`scrollWrapperComponentInstanceId\`
- Can work without ScrollWrapper for basic drag selection (auto-scroll gracefully disabled)
`,
},
},
},
argTypes: {
selectableItemsContainerRef: { control: false },
onDragSelectionChange: { control: false },
onDragSelectionStart: { control: false },
onDragSelectionEnd: { control: false },
},
};
export default meta;
type Story = StoryObj<typeof DragSelect>;
export const Default: Story = {
render: () => <BasicDragSelectDemo />,
parameters: {
docs: {
description: {
story:
'Basic drag selection with a grid of selectable items. Works without ScrollWrapper - drag selection is universal and can work in any container.',
},
},
},
};
export const WithAutoScroll: Story = {
render: () => <ScrollableDragSelectDemo />,
parameters: {
docs: {
description: {
story:
'Drag selection with auto-scroll support. The container will automatically scroll when dragging near the edges. Uses ScrollWrapper for auto-scroll functionality.',
},
},
},
};
export const InteractiveDragSelection: Story = {
render: () => <BasicDragSelectDemo />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText('Item 1');
const container = canvasElement
.querySelector('[data-selectable-id="item-0"]')
?.closest('div');
if (isDefined(container)) {
await userEvent.pointer([
{ target: container, coords: { x: 50, y: 50 } },
{ keys: '[MouseLeft>]', coords: { x: 50, y: 50 } },
{ coords: { x: 200, y: 150 } },
{ keys: '[/MouseLeft]' },
]);
}
},
parameters: {
docs: {
description: {
story:
'Automated interaction test showing drag selection behavior. Watch as items get selected during the drag operation.',
},
},
},
};
export const Disabled: Story = {
render: () => <BasicDragSelectDemo disableSelection={true} />,
parameters: {
docs: {
description: {
story:
'Component without drag selection enabled. Items are displayed but cannot be selected via dragging.',
},
},
},
};

View File

@ -0,0 +1,148 @@
import { render } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { PointerEventListener } from '@/ui/utilities/pointer-event/types/PointerEventListener';
import { useDragSelect } from '../../hooks/useDragSelect';
import { DragSelect } from '../DragSelect';
jest.mock('../../hooks/useDragSelect');
jest.mock('../../hooks/useDragSelectWithAutoScroll', () => ({
useDragSelectWithAutoScroll: () => ({
handleAutoScroll: jest.fn(),
}),
}));
jest.mock('@/ui/utilities/pointer-event/hooks/useTrackPointer', () => ({
useTrackPointer: ({ onMouseDown }: { onMouseDown: PointerEventListener }) => {
(window as any).trackPointerCallbacks = {
onMouseDown,
};
},
}));
const mockUseDragSelect = useDragSelect as jest.MockedFunction<
typeof useDragSelect
>;
describe('DragSelect', () => {
const mockOnDragSelectionChange = jest.fn();
const mockSelectableContainer = document.createElement('div');
const mockContainerRef = { current: mockSelectableContainer };
beforeEach(() => {
jest.clearAllMocks();
mockUseDragSelect.mockReturnValue({
isDragSelectionStartEnabled: jest.fn().mockReturnValue(true),
setDragSelectionStartEnabled: jest.fn(),
});
mockSelectableContainer.getBoundingClientRect = jest.fn().mockReturnValue({
left: 100,
top: 100,
width: 500,
height: 400,
});
(window as any).trackPointerCallbacks = null;
});
const renderDragSelect = (selectionBoundaryClass?: string) => {
return render(
<RecoilRoot>
<DragSelect
selectableItemsContainerRef={mockContainerRef}
onDragSelectionChange={mockOnDragSelectionChange}
selectionBoundaryClass={selectionBoundaryClass}
/>
</RecoilRoot>,
);
};
it('should not start selection when target has data-select-disable', () => {
renderDragSelect();
const callbacks = (window as any).trackPointerCallbacks;
const mockTarget = document.createElement('div');
mockTarget.dataset.selectDisable = 'true';
mockSelectableContainer.appendChild(mockTarget);
mockSelectableContainer.contains = jest.fn().mockReturnValue(true);
const mockEvent = {
target: mockTarget,
preventDefault: jest.fn(),
};
callbacks.onMouseDown({
x: 150,
y: 150,
event: mockEvent,
});
expect(mockEvent.preventDefault).toHaveBeenCalled();
});
it('should handle null container ref without crashing', () => {
const nullRef = { current: null };
expect(() => {
render(
<RecoilRoot>
<DragSelect
selectableItemsContainerRef={nullRef}
onDragSelectionChange={mockOnDragSelectionChange}
/>
</RecoilRoot>,
);
}).not.toThrow();
});
it('should use selection boundary class when provided', () => {
const selectionBoundaryClass = 'custom-boundary';
renderDragSelect(selectionBoundaryClass);
const callbacks = (window as any).trackPointerCallbacks;
const mockTarget = document.createElement('div');
const mockBoundaryElement = document.createElement('div');
mockBoundaryElement.className = selectionBoundaryClass;
mockSelectableContainer.closest = jest
.fn()
.mockReturnValue(mockBoundaryElement);
mockBoundaryElement.contains = jest.fn().mockReturnValue(true);
callbacks.onMouseDown({
x: 150,
y: 150,
event: { target: mockTarget, preventDefault: jest.fn() },
});
expect(mockSelectableContainer.closest).toHaveBeenCalledWith(
`.${selectionBoundaryClass}`,
);
});
it('should work without scrollWrapperComponentInstanceId (universal compatibility)', () => {
expect(() => {
render(
<RecoilRoot>
<DragSelect
selectableItemsContainerRef={mockContainerRef}
onDragSelectionChange={mockOnDragSelectionChange}
/>
</RecoilRoot>,
);
}).not.toThrow();
const callbacks = (window as any).trackPointerCallbacks;
const mockTarget = document.createElement('div');
mockSelectableContainer.appendChild(mockTarget);
mockSelectableContainer.contains = jest.fn().mockReturnValue(true);
expect(() => {
callbacks.onMouseDown({
x: 150,
y: 150,
event: { target: mockTarget, preventDefault: jest.fn() },
});
}).not.toThrow();
});
});

View File

@ -0,0 +1 @@
export const AUTO_SCROLL_EDGE_THRESHOLD_PX = 20;

View File

@ -0,0 +1 @@
export const AUTO_SCROLL_MAX_SPEED_PX = 15;

View File

@ -0,0 +1,2 @@
export const RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS =
'record-index-container-gater-for-drag-select';

View File

@ -0,0 +1,243 @@
import { renderHook } from '@testing-library/react';
import { useComponentInstanceStateContext } from '@/ui/utilities/state/component-state/hooks/useComponentInstanceStateContext';
import { useDragSelectWithAutoScroll } from '../useDragSelectWithAutoScroll';
jest.mock(
'@/ui/utilities/state/component-state/hooks/useComponentInstanceStateContext',
() => ({
useComponentInstanceStateContext: jest.fn(),
}),
);
describe('useDragSelectWithAutoScroll', () => {
const mockUseComponentInstanceStateContext = jest.mocked(
useComponentInstanceStateContext,
);
const createMockScrollElement = (bounds = {}) => {
const defaultBounds = {
top: 100,
left: 100,
bottom: 400,
right: 600,
width: 500,
height: 300,
};
const element = {
getBoundingClientRect: jest
.fn()
.mockReturnValue({ ...defaultBounds, ...bounds }),
scrollTo: jest.fn(),
scrollTop: 50,
scrollLeft: 25,
};
return element;
};
const originalGetElementById = document.getElementById;
afterEach(() => {
document.getElementById = originalGetElementById;
jest.clearAllMocks();
});
describe('instance ID resolution', () => {
it('should prioritize explicit scrollWrapperComponentInstanceId over context', () => {
mockUseComponentInstanceStateContext.mockReturnValue({
instanceId: 'context-instance',
});
const mockElement = createMockScrollElement();
document.getElementById = jest
.fn()
.mockImplementation((id) =>
id === 'scroll-wrapper-explicit-instance' ? mockElement : null,
);
const { result } = renderHook(() =>
useDragSelectWithAutoScroll({
scrollWrapperComponentInstanceId: 'explicit-instance',
}),
);
result.current.handleAutoScroll(105, 250);
expect(document.getElementById).toHaveBeenCalledWith(
'scroll-wrapper-explicit-instance',
);
expect(mockElement.scrollTo).toHaveBeenCalled();
});
it('should use context instance ID when no explicit ID provided', () => {
mockUseComponentInstanceStateContext.mockReturnValue({
instanceId: 'context-instance',
});
const mockElement = createMockScrollElement();
document.getElementById = jest
.fn()
.mockImplementation((id) =>
id === 'scroll-wrapper-context-instance' ? mockElement : null,
);
const { result } = renderHook(() => useDragSelectWithAutoScroll({}));
result.current.handleAutoScroll(105, 250);
expect(document.getElementById).toHaveBeenCalledWith(
'scroll-wrapper-context-instance',
);
expect(mockElement.scrollTo).toHaveBeenCalled();
});
it('should not attempt scrolling when no instance ID available', () => {
mockUseComponentInstanceStateContext.mockReturnValue(null);
document.getElementById = jest.fn();
const { result } = renderHook(() => useDragSelectWithAutoScroll({}));
result.current.handleAutoScroll(105, 105);
expect(document.getElementById).not.toHaveBeenCalled();
});
});
describe('edge detection and scroll calculations', () => {
let mockElement: ReturnType<typeof createMockScrollElement>;
beforeEach(() => {
mockUseComponentInstanceStateContext.mockReturnValue({
instanceId: 'test-instance',
});
mockElement = createMockScrollElement({
top: 100,
left: 100,
bottom: 400,
right: 600,
});
document.getElementById = jest.fn().mockReturnValue(mockElement);
});
it('should calculate correct scroll amounts for vertical scrolling', () => {
const { result } = renderHook(() => useDragSelectWithAutoScroll({}));
result.current.handleAutoScroll(300, 105);
expect(mockElement.scrollTo).toHaveBeenCalledWith({
top: 35,
});
jest.clearAllMocks();
result.current.handleAutoScroll(300, 395);
expect(mockElement.scrollTo).toHaveBeenCalledWith({
top: 65,
});
});
it('should calculate correct scroll amounts for horizontal scrolling', () => {
const { result } = renderHook(() => useDragSelectWithAutoScroll({}));
result.current.handleAutoScroll(105, 250);
expect(mockElement.scrollTo).toHaveBeenCalledWith({
left: 10,
behavior: 'auto',
});
jest.clearAllMocks();
result.current.handleAutoScroll(595, 250);
expect(mockElement.scrollTo).toHaveBeenCalledWith({
left: 40,
behavior: 'auto',
});
});
it('should prevent negative scroll values', () => {
mockElement.scrollTop = 5;
mockElement.scrollLeft = 3;
const { result } = renderHook(() => useDragSelectWithAutoScroll({}));
result.current.handleAutoScroll(105, 105);
expect(mockElement.scrollTo).toHaveBeenCalledWith({
top: 0,
});
expect(mockElement.scrollTo).toHaveBeenCalledWith({
left: 0,
behavior: 'auto',
});
});
it('should not scroll when mouse is in safe zone', () => {
const { result } = renderHook(() => useDragSelectWithAutoScroll({}));
result.current.handleAutoScroll(350, 250);
expect(mockElement.scrollTo).not.toHaveBeenCalled();
result.current.handleAutoScroll(125, 250);
result.current.handleAutoScroll(575, 250);
result.current.handleAutoScroll(300, 125);
result.current.handleAutoScroll(300, 375);
expect(mockElement.scrollTo).not.toHaveBeenCalled();
});
it('should handle exact edge threshold boundaries', () => {
const { result } = renderHook(() => useDragSelectWithAutoScroll({}));
result.current.handleAutoScroll(119, 250);
expect(mockElement.scrollTo).toHaveBeenCalledWith({
left: 10,
behavior: 'auto',
});
jest.clearAllMocks();
result.current.handleAutoScroll(120, 250);
expect(mockElement.scrollTo).not.toHaveBeenCalled();
});
});
describe('error handling', () => {
beforeEach(() => {
mockUseComponentInstanceStateContext.mockReturnValue({
instanceId: 'test-instance',
});
});
it('should handle missing DOM element gracefully', () => {
document.getElementById = jest.fn().mockReturnValue(null);
const { result } = renderHook(() => useDragSelectWithAutoScroll({}));
expect(() => {
result.current.handleAutoScroll(105, 105);
}).not.toThrow();
});
it('should handle element without getBoundingClientRect', () => {
const brokenElement = { scrollTo: jest.fn() };
document.getElementById = jest.fn().mockReturnValue(brokenElement);
const { result } = renderHook(() => useDragSelectWithAutoScroll({}));
expect(() => {
result.current.handleAutoScroll(105, 105);
}).toThrow();
});
});
});

View File

@ -0,0 +1,96 @@
import { useCallback, useMemo } from 'react';
import { ScrollWrapperComponentInstanceContext } from '@/ui/utilities/scroll/states/contexts/ScrollWrapperComponentInstanceContext';
import { useComponentInstanceStateContext } from '@/ui/utilities/state/component-state/hooks/useComponentInstanceStateContext';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { AUTO_SCROLL_EDGE_THRESHOLD_PX } from '../constants/AutoScrollEdgeThresholdPx';
import { AUTO_SCROLL_MAX_SPEED_PX } from '../constants/AutoScrollMaxSpeedPx';
type UseDragSelectWithAutoScrollProps = {
scrollWrapperComponentInstanceId?: string;
};
export const useDragSelectWithAutoScroll = ({
scrollWrapperComponentInstanceId,
}: UseDragSelectWithAutoScrollProps) => {
const instanceStateContext = useComponentInstanceStateContext(
ScrollWrapperComponentInstanceContext,
);
const instanceIdFromContext = instanceStateContext?.instanceId;
const scrollWrapperInstanceId = useMemo(() => {
if (isNonEmptyString(scrollWrapperComponentInstanceId)) {
return scrollWrapperComponentInstanceId;
} else if (isNonEmptyString(instanceIdFromContext)) {
return instanceIdFromContext;
}
return null;
}, [scrollWrapperComponentInstanceId, instanceIdFromContext]);
const hasScrollWrapper = isDefined(scrollWrapperInstanceId);
const handleAutoScroll = useCallback(
(mouseX: number, mouseY: number) => {
if (!hasScrollWrapper || !scrollWrapperInstanceId) {
return;
}
const scrollWrapperHTMLElement = document.getElementById(
`scroll-wrapper-${scrollWrapperInstanceId}`,
);
if (!scrollWrapperHTMLElement) {
return;
}
const containerRect = scrollWrapperHTMLElement.getBoundingClientRect();
const nearTop =
mouseY - containerRect.top < AUTO_SCROLL_EDGE_THRESHOLD_PX;
const nearBottom =
containerRect.bottom - mouseY < AUTO_SCROLL_EDGE_THRESHOLD_PX;
const nearLeft =
mouseX - containerRect.left < AUTO_SCROLL_EDGE_THRESHOLD_PX;
const nearRight =
containerRect.right - mouseX < AUTO_SCROLL_EDGE_THRESHOLD_PX;
const currentScrollTop = scrollWrapperHTMLElement.scrollTop;
const currentScrollLeft = scrollWrapperHTMLElement.scrollLeft;
if (nearTop) {
const newScrollTop = Math.max(
0,
currentScrollTop - AUTO_SCROLL_MAX_SPEED_PX,
);
scrollWrapperHTMLElement.scrollTo({ top: newScrollTop });
} else if (nearBottom) {
const newScrollTop = currentScrollTop + AUTO_SCROLL_MAX_SPEED_PX;
scrollWrapperHTMLElement.scrollTo({ top: newScrollTop });
}
if (nearLeft) {
const newScrollLeft = Math.max(
0,
currentScrollLeft - AUTO_SCROLL_MAX_SPEED_PX,
);
scrollWrapperHTMLElement.scrollTo({
left: newScrollLeft,
behavior: 'auto',
});
} else if (nearRight) {
const newScrollLeft = currentScrollLeft + AUTO_SCROLL_MAX_SPEED_PX;
scrollWrapperHTMLElement.scrollTo({
left: newScrollLeft,
behavior: 'auto',
});
}
},
[hasScrollWrapper, scrollWrapperInstanceId],
);
return {
handleAutoScroll,
};
};

View File

@ -0,0 +1,6 @@
export type SelectionBox = {
top: number;
left: number;
width: number;
height: number;
};

View File

@ -0,0 +1,94 @@
import { SelectionBox } from '../../types/SelectionBox';
import { isValidSelectionStart } from '../selectionBoxValidation';
describe('selectionBoxValidation', () => {
describe('isValidSelectionStart', () => {
it('should return true for selection box with minimum valid size', () => {
const selectionBox: SelectionBox = {
top: 10,
left: 10,
width: 5,
height: 5,
};
expect(isValidSelectionStart(selectionBox)).toBe(true);
});
it('should return false for selection box with zero width', () => {
const selectionBox: SelectionBox = {
top: 10,
left: 10,
width: 0,
height: 5,
};
expect(isValidSelectionStart(selectionBox)).toBe(false);
});
it('should return false for selection box with zero height', () => {
const selectionBox: SelectionBox = {
top: 10,
left: 10,
width: 5,
height: 0,
};
expect(isValidSelectionStart(selectionBox)).toBe(false);
});
it('should return false for selection box with both zero width and height', () => {
const selectionBox: SelectionBox = {
top: 10,
left: 10,
width: 0,
height: 0,
};
expect(isValidSelectionStart(selectionBox)).toBe(false);
});
it('should return true for large selection box', () => {
const selectionBox: SelectionBox = {
top: 0,
left: 0,
width: 1000,
height: 800,
};
expect(isValidSelectionStart(selectionBox)).toBe(true);
});
it('should handle negative dimensions', () => {
const selectionBox: SelectionBox = {
top: 10,
left: 10,
width: -5,
height: 5,
};
expect(isValidSelectionStart(selectionBox)).toBe(false);
});
it('should handle fractional dimensions', () => {
const selectionBox: SelectionBox = {
top: 10.5,
left: 10.5,
width: 2.3,
height: 3.7,
};
expect(isValidSelectionStart(selectionBox)).toBe(false);
});
it('should return true for fractional dimensions with large area', () => {
const selectionBox: SelectionBox = {
top: 10.5,
left: 10.5,
width: 4.0,
height: 3.0,
};
expect(isValidSelectionStart(selectionBox)).toBe(true);
});
});
});

View File

@ -0,0 +1,9 @@
import { SelectionBox } from '@/ui/utilities/drag-select/types/SelectionBox';
const calculateBoxArea = (box: SelectionBox): number => {
return box.width * box.height;
};
export const isValidSelectionStart = (box: SelectionBox): boolean => {
return calculateBoxArea(box) > 10;
};

View File

@ -17,7 +17,11 @@ describe('useTrackPointer', () => {
document.dispatchEvent(event);
});
expect(onMouseDown).toHaveBeenCalledWith(150, 250);
expect(onMouseDown).toHaveBeenCalledWith({
x: 150,
y: 250,
event: expect.any(MouseEvent),
});
});
it('Should call onMouseUp when mouse up event is triggered', () => {
@ -34,7 +38,11 @@ describe('useTrackPointer', () => {
document.dispatchEvent(event);
});
expect(onMouseUp).toHaveBeenCalledWith(200, 300);
expect(onMouseUp).toHaveBeenCalledWith({
x: 200,
y: 300,
event: expect.any(MouseEvent),
});
});
it('Should call onInternalMouseMove when mouse move event is triggered', () => {
@ -51,6 +59,77 @@ describe('useTrackPointer', () => {
document.dispatchEvent(event);
});
expect(onInternalMouseMove).toHaveBeenCalledWith(150, 250);
expect(onInternalMouseMove).toHaveBeenCalledWith({
x: 150,
y: 250,
event: expect.any(MouseEvent),
});
});
it('Should pass the correct event object to the callback', () => {
const onMouseDown = jest.fn();
renderHook(() =>
useTrackPointer({
onMouseDown,
}),
);
act(() => {
const event = new MouseEvent('mousedown', { clientX: 100, clientY: 200 });
document.dispatchEvent(event);
});
const calledWith = onMouseDown.mock.calls[0][0];
expect(calledWith.event).toBeInstanceOf(MouseEvent);
expect(calledWith.event.type).toBe('mousedown');
});
it('Should handle touch events correctly', () => {
const onMouseDown = jest.fn();
renderHook(() =>
useTrackPointer({
onMouseDown,
}),
);
act(() => {
const touchEvent = new TouchEvent('touchstart', {
changedTouches: [
{
clientX: 120,
clientY: 180,
} as Touch,
],
});
document.dispatchEvent(touchEvent);
});
if (onMouseDown.mock.calls.length > 0) {
const calledWith = onMouseDown.mock.calls[0][0];
expect(calledWith.x).toBe(120);
expect(calledWith.y).toBe(180);
expect(calledWith.event).toBeInstanceOf(TouchEvent);
}
});
it('Should not track pointer when shouldTrackPointer is false', () => {
const onMouseDown = jest.fn();
renderHook(() =>
useTrackPointer({
shouldTrackPointer: false,
onMouseDown,
}),
);
act(() => {
const event = new MouseEvent('mousedown', { clientX: 150, clientY: 250 });
document.dispatchEvent(event);
});
expect(onMouseDown).not.toHaveBeenCalled();
});
});

View File

@ -1,7 +1,6 @@
import { PointerEventListener } from '@/ui/utilities/pointer-event/types/PointerEventListener';
import { useCallback, useEffect } from 'react';
type MouseListener = (positionX: number, positionY: number) => void;
export const useTrackPointer = ({
shouldTrackPointer = true,
onMouseMove,
@ -9,9 +8,9 @@ export const useTrackPointer = ({
onMouseUp,
}: {
shouldTrackPointer?: boolean;
onMouseMove?: MouseListener;
onMouseDown?: MouseListener;
onMouseUp?: MouseListener;
onMouseMove?: PointerEventListener;
onMouseDown?: PointerEventListener;
onMouseUp?: PointerEventListener;
}) => {
const extractPosition = useCallback((event: MouseEvent | TouchEvent) => {
const clientX =
@ -25,7 +24,7 @@ export const useTrackPointer = ({
const onInternalMouseMove = useCallback(
(event: MouseEvent | TouchEvent) => {
const { clientX, clientY } = extractPosition(event);
onMouseMove?.(clientX, clientY);
onMouseMove?.({ x: clientX, y: clientY, event });
},
[onMouseMove, extractPosition],
);
@ -33,7 +32,7 @@ export const useTrackPointer = ({
const onInternalMouseDown = useCallback(
(event: MouseEvent | TouchEvent) => {
const { clientX, clientY } = extractPosition(event);
onMouseDown?.(clientX, clientY);
onMouseDown?.({ x: clientX, y: clientY, event });
},
[onMouseDown, extractPosition],
);
@ -41,7 +40,7 @@ export const useTrackPointer = ({
const onInternalMouseUp = useCallback(
(event: MouseEvent | TouchEvent) => {
const { clientX, clientY } = extractPosition(event);
onMouseUp?.(clientX, clientY);
onMouseUp?.({ x: clientX, y: clientY, event });
},
[onMouseUp, extractPosition],
);

View File

@ -0,0 +1,9 @@
export type PointerEventListener = ({
x,
y,
event,
}: {
x: number;
y: number;
event: MouseEvent | TouchEvent;
}) => void;

View File

@ -24,18 +24,6 @@ __metadata:
languageName: node
linkType: hard
"@air/react-drag-to-select@npm:^5.0.8":
version: 5.0.8
resolution: "@air/react-drag-to-select@npm:5.0.8"
dependencies:
react-style-object-to-css: "npm:^1.1.2"
peerDependencies:
react: 16 - 18
react-dom: 16 - 18
checksum: 10c0/a7005e863ab0dc93ae789a8093d8284f6f4c519a8f23c54136f493e014b2a10dcab42c83521e75a83fdac5806d55a38f417b108386c684b050a4fbc4071fb419
languageName: node
linkType: hard
"@algolia/autocomplete-core@npm:1.9.3":
version: 1.9.3
resolution: "@algolia/autocomplete-core@npm:1.9.3"
@ -50271,13 +50259,6 @@ __metadata:
languageName: node
linkType: hard
"react-style-object-to-css@npm:^1.1.2":
version: 1.1.2
resolution: "react-style-object-to-css@npm:1.1.2"
checksum: 10c0/c2154dd99723dbc2c359a167401d65b8bae300b9e7ed0c7609b617340d941faa4af822d855257b6f0e36522e30665650c7263f4ed8d556217e246048fbc7bb7c
languageName: node
linkType: hard
"react-style-singleton@npm:^2.2.1":
version: 2.2.1
resolution: "react-style-singleton@npm:2.2.1"
@ -55783,7 +55764,6 @@ __metadata:
version: 0.0.0-use.local
resolution: "twenty@workspace:."
dependencies:
"@air/react-drag-to-select": "npm:^5.0.8"
"@apollo/client": "npm:^3.7.17"
"@apollo/server": "npm:^4.7.3"
"@aws-sdk/client-lambda": "npm:^3.614.0"