From 524a1d78d2d72f09a4853b86fb0985be33c05135 Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Mon, 26 May 2025 15:28:22 +0530 Subject: [PATCH] Refactor drag selection: Replace external library with custom implementation and add auto-scroll (#12134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: Screenshot 2025-05-19 at 23 58 54 after: Screenshot 2025-05-19 at 23 56 40 --------- Co-authored-by: Charles Bochet --- package.json | 1 - .../record-board/components/RecordBoard.tsx | 25 +- .../components/RecordBoardColumn.tsx | 2 + .../components/RecordIndexContainerGater.tsx | 5 +- .../record-table/components/RecordTable.tsx | 1 + .../components/RecordTableContent.tsx | 21 +- .../components/RecordTableCellCheckbox.tsx | 2 +- .../components/RecordTableHeaderCell.tsx | 16 +- .../RecordTableHeaderCheckboxColumn.tsx | 2 +- .../drag-select/components/DragSelect.tsx | 270 ++++++++++++++---- .../__stories__/DragSelect.stories.tsx | 270 ++++++++++++++++++ .../components/__tests__/DragSelect.test.tsx | 148 ++++++++++ .../constants/AutoScrollEdgeThresholdPx.ts | 1 + .../constants/AutoScrollMaxSpeedPx.ts | 1 + .../RecordIndecDragSelectBoundaryClass.ts | 2 + .../useDragSelectWithAutoScroll.test.tsx | 243 ++++++++++++++++ .../hooks/useDragSelectWithAutoScroll.ts | 96 +++++++ .../drag-select/types/SelectionBox.ts | 6 + .../__tests__/selectionBoxValidation.test.ts | 94 ++++++ .../utils/selectionBoxValidation.ts | 9 + .../hooks/__tests__/useTrackPointer.test.tsx | 85 +++++- .../pointer-event/hooks/useTrackPointer.ts | 15 +- .../types/PointerEventListener.ts | 9 + yarn.lock | 20 -- 24 files changed, 1244 insertions(+), 100 deletions(-) create mode 100644 packages/twenty-front/src/modules/ui/utilities/drag-select/components/__stories__/DragSelect.stories.tsx create mode 100644 packages/twenty-front/src/modules/ui/utilities/drag-select/components/__tests__/DragSelect.test.tsx create mode 100644 packages/twenty-front/src/modules/ui/utilities/drag-select/constants/AutoScrollEdgeThresholdPx.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/drag-select/constants/AutoScrollMaxSpeedPx.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/drag-select/constants/RecordIndecDragSelectBoundaryClass.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/drag-select/hooks/__tests__/useDragSelectWithAutoScroll.test.tsx create mode 100644 packages/twenty-front/src/modules/ui/utilities/drag-select/hooks/useDragSelectWithAutoScroll.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/drag-select/types/SelectionBox.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/drag-select/utils/__tests__/selectionBoxValidation.test.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/drag-select/utils/selectionBoxValidation.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/pointer-event/types/PointerEventListener.ts diff --git a/package.json b/package.json index 27ccdf430..8c79a5a74 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx index f5f55bd8a..cbc4446ed 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx @@ -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 = () => { ))} + + - diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumn.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumn.tsx index 313a5cc4b..01420907d 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumn.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumn.tsx @@ -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; diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainerGater.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainerGater.tsx index 4c928d20a..60b6881f5 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainerGater.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainerGater.tsx @@ -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 = () => { /> - + diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTable.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTable.tsx index 6c7d7df37..b08112189 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTable.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTable.tsx @@ -76,6 +76,7 @@ export const RecordTable = () => { handleDragSelectionEnd={handleDragSelectionEnd} setRowSelected={setRowSelected} hasRecordGroups={hasRecordGroups} + recordTableId={recordTableId} /> )} diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableContent.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableContent.tsx index 4d96d0537..6239caeaa 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableContent.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableContent.tsx @@ -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; 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(null); const handleDragStart = () => { setIsDragging(true); @@ -44,7 +55,7 @@ export const RecordTableContent = ({ }; return ( - <> + {hasRecordGroups ? ( @@ -56,11 +67,13 @@ export const RecordTableContent = ({ - + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellCheckbox.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellCheckbox.tsx index 20c488cbf..c03164785 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellCheckbox.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellCheckbox.tsx @@ -32,7 +32,7 @@ export const RecordTableCellCheckbox = () => { return ( - + diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderCell.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderCell.tsx index f195ec216..d6befba3d 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderCell.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderCell.tsx @@ -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( + ({ x }) => { + setInitialPointerPositionX(x); + }, + [], + ); const [iconVisibility, setIconVisibility] = useState(false); - const handleResizeHandlerMove = useCallback( - (positionX: number) => { + const handleResizeHandlerMove = useCallback( + ({ x }) => { if (!initialPointerPositionX) return; - setResizeFieldOffset(positionX - initialPointerPositionX); + setResizeFieldOffset(x - initialPointerPositionX); }, [setResizeFieldOffset, initialPointerPositionX], ); diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderCheckboxColumn.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderCheckboxColumn.tsx index b7d2a95e3..bb1ecc2e0 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderCheckboxColumn.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderCheckboxColumn.tsx @@ -82,7 +82,7 @@ export const RecordTableHeaderCheckboxColumn = () => { - + ; + selectableItemsContainerRef: RefObject; 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` + 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(null); + const [endPoint, setEndPoint] = useState(null); + const [selectionBox, setSelectionBox] = useState(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 ; + return ( + isDragging && + isSelecting && + isDefined(selectionBox) && ( + + ) + ); }; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-select/components/__stories__/DragSelect.stories.tsx b/packages/twenty-front/src/modules/ui/utilities/drag-select/components/__stories__/DragSelect.stories.tsx new file mode 100644 index 000000000..cbe4c1cd5 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-select/components/__stories__/DragSelect.stories.tsx @@ -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) => ( + + {children} + +); + +type BasicDragSelectDemoProps = { + itemCount?: number; + disableSelection?: boolean; +}; + +const BasicDragSelectDemo = ({ + itemCount = 12, + disableSelection = false, +}: BasicDragSelectDemoProps) => { + const containerRef = useRef(null); + const [selectedItems, setSelectedItems] = useState>(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 ( + + + {Array.from({ length: itemCount }, (_, index) => ( + + Item {index + 1} + + ))} + + + {!disableSelection && ( + {}} + onDragSelectionEnd={() => {}} + /> + )} + + ); +}; + +const ScrollableDragSelectDemo = () => { + const containerRef = useRef(null); + const [selectedItems, setSelectedItems] = useState>(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 ( + +
+ + {Array.from({ length: 36 }, (_, index) => ( + + Item {index + 1} + + ))} + + + +
+
+ ); +}; + +const meta: Meta = { + 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; + +export const Default: Story = { + render: () => , + 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: () => , + 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: () => , + 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: () => , + parameters: { + docs: { + description: { + story: + 'Component without drag selection enabled. Items are displayed but cannot be selected via dragging.', + }, + }, + }, +}; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-select/components/__tests__/DragSelect.test.tsx b/packages/twenty-front/src/modules/ui/utilities/drag-select/components/__tests__/DragSelect.test.tsx new file mode 100644 index 000000000..579dc1750 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-select/components/__tests__/DragSelect.test.tsx @@ -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( + + + , + ); + }; + + 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( + + + , + ); + }).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( + + + , + ); + }).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(); + }); +}); diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-select/constants/AutoScrollEdgeThresholdPx.ts b/packages/twenty-front/src/modules/ui/utilities/drag-select/constants/AutoScrollEdgeThresholdPx.ts new file mode 100644 index 000000000..fb2fbfe44 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-select/constants/AutoScrollEdgeThresholdPx.ts @@ -0,0 +1 @@ +export const AUTO_SCROLL_EDGE_THRESHOLD_PX = 20; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-select/constants/AutoScrollMaxSpeedPx.ts b/packages/twenty-front/src/modules/ui/utilities/drag-select/constants/AutoScrollMaxSpeedPx.ts new file mode 100644 index 000000000..ef0a5810e --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-select/constants/AutoScrollMaxSpeedPx.ts @@ -0,0 +1 @@ +export const AUTO_SCROLL_MAX_SPEED_PX = 15; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-select/constants/RecordIndecDragSelectBoundaryClass.ts b/packages/twenty-front/src/modules/ui/utilities/drag-select/constants/RecordIndecDragSelectBoundaryClass.ts new file mode 100644 index 000000000..ea3e513d0 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-select/constants/RecordIndecDragSelectBoundaryClass.ts @@ -0,0 +1,2 @@ +export const RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS = + 'record-index-container-gater-for-drag-select'; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-select/hooks/__tests__/useDragSelectWithAutoScroll.test.tsx b/packages/twenty-front/src/modules/ui/utilities/drag-select/hooks/__tests__/useDragSelectWithAutoScroll.test.tsx new file mode 100644 index 000000000..7eda05a3a --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-select/hooks/__tests__/useDragSelectWithAutoScroll.test.tsx @@ -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; + + 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(); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-select/hooks/useDragSelectWithAutoScroll.ts b/packages/twenty-front/src/modules/ui/utilities/drag-select/hooks/useDragSelectWithAutoScroll.ts new file mode 100644 index 000000000..f9015df79 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-select/hooks/useDragSelectWithAutoScroll.ts @@ -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, + }; +}; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-select/types/SelectionBox.ts b/packages/twenty-front/src/modules/ui/utilities/drag-select/types/SelectionBox.ts new file mode 100644 index 000000000..04d35f4bc --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-select/types/SelectionBox.ts @@ -0,0 +1,6 @@ +export type SelectionBox = { + top: number; + left: number; + width: number; + height: number; +}; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-select/utils/__tests__/selectionBoxValidation.test.ts b/packages/twenty-front/src/modules/ui/utilities/drag-select/utils/__tests__/selectionBoxValidation.test.ts new file mode 100644 index 000000000..9d1901664 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-select/utils/__tests__/selectionBoxValidation.test.ts @@ -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); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-select/utils/selectionBoxValidation.ts b/packages/twenty-front/src/modules/ui/utilities/drag-select/utils/selectionBoxValidation.ts new file mode 100644 index 000000000..422ba2280 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-select/utils/selectionBoxValidation.ts @@ -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; +}; diff --git a/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/__tests__/useTrackPointer.test.tsx b/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/__tests__/useTrackPointer.test.tsx index 0f8b3dfbc..f313a4861 100644 --- a/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/__tests__/useTrackPointer.test.tsx +++ b/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/__tests__/useTrackPointer.test.tsx @@ -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(); }); }); diff --git a/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/useTrackPointer.ts b/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/useTrackPointer.ts index 82998d6c6..e7a7aeffe 100644 --- a/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/useTrackPointer.ts +++ b/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/useTrackPointer.ts @@ -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], ); diff --git a/packages/twenty-front/src/modules/ui/utilities/pointer-event/types/PointerEventListener.ts b/packages/twenty-front/src/modules/ui/utilities/pointer-event/types/PointerEventListener.ts new file mode 100644 index 000000000..78d0c8265 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/pointer-event/types/PointerEventListener.ts @@ -0,0 +1,9 @@ +export type PointerEventListener = ({ + x, + y, + event, +}: { + x: number; + y: number; + event: MouseEvent | TouchEvent; +}) => void; diff --git a/yarn.lock b/yarn.lock index e5f789ede..8b3d93e12 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"