diff --git a/packages/twenty-front/jest.config.mjs b/packages/twenty-front/jest.config.mjs index 6a09f82205..98bd2e6012 100644 --- a/packages/twenty-front/jest.config.mjs +++ b/packages/twenty-front/jest.config.mjs @@ -27,8 +27,8 @@ const jestConfig = { testEnvironmentOptions: {}, transformIgnorePatterns: [ - '/node_modules/(?!(twenty-ui|apollo-upload-client|extract-files|is-plain-obj)/.*)', - '../../node_modules/(?!(twenty-ui|apollo-upload-client|extract-files|is-plain-obj)/.*)', + '/node_modules/(?!(twenty-ui|apollo-upload-client|extract-files|is-plain-obj|@preact/signals-core)/.*)', + '../../node_modules/(?!(twenty-ui|apollo-upload-client|extract-files|is-plain-obj|@preact/signals-core)/.*)', '../../twenty-ui/', ], transform: { diff --git a/packages/twenty-front/package.json b/packages/twenty-front/package.json index 5923755afb..61af16bfbe 100644 --- a/packages/twenty-front/package.json +++ b/packages/twenty-front/package.json @@ -44,7 +44,6 @@ "@fontsource/inter": "^5.2.8", "@graphiql/plugin-explorer": "^5.1.2", "@graphiql/react": "^0.37.6", - "@hello-pangea/dnd": "^18.0.1", "@hookform/resolvers": "^5.2.2", "@linaria/core": "^7.0.0", "@linaria/react": "^7.0.1", diff --git a/packages/twenty-front/setupTests.ts b/packages/twenty-front/setupTests.ts index 360355548e..05d26d9225 100644 --- a/packages/twenty-front/setupTests.ts +++ b/packages/twenty-front/setupTests.ts @@ -38,6 +38,19 @@ if (typeof window !== 'undefined') { }); } +// jsdom does not implement ResizeObserver; @dnd-kit/dom expects it at import +// time. +class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} +} + +if (globalThis.ResizeObserver === undefined) { + globalThis.ResizeObserver = + ResizeObserverMock as unknown as typeof ResizeObserver; +} + // Add Jest matchers for toThrowError and other missing methods declare global { namespace jest { diff --git a/packages/twenty-front/src/modules/command-menu-item/edit/components/SidePanelCommandMenuItemEditPage.tsx b/packages/twenty-front/src/modules/command-menu-item/edit/components/SidePanelCommandMenuItemEditPage.tsx index 4924aea998..53afb3e500 100644 --- a/packages/twenty-front/src/modules/command-menu-item/edit/components/SidePanelCommandMenuItemEditPage.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/edit/components/SidePanelCommandMenuItemEditPage.tsx @@ -14,7 +14,7 @@ import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableIt import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList'; import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; -import { type DropResult } from '@hello-pangea/dnd'; +import { type DraggableListDropResult } from '@/ui/layout/draggable-list/types/DraggableListDropResult'; import { styled } from '@linaria/react'; import { useLingui } from '@lingui/react/macro'; import { ContextStorePageType } from 'twenty-shared/types'; @@ -145,7 +145,7 @@ export const SidePanelCommandMenuItemEditPage = () => { /> ); - const handlePinnedDragEnd = (result: DropResult) => { + const handlePinnedDragEnd = (result: DraggableListDropResult) => { const { source, destination, draggableId } = result; if (!isDefined(destination)) { diff --git a/packages/twenty-front/src/modules/navigation-menu-item/display/dnd/hooks/useNavigationMenuItemDndKit.ts b/packages/twenty-front/src/modules/navigation-menu-item/display/dnd/hooks/useNavigationMenuItemDndKit.ts index 99c540cdd1..3cdb139236 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/display/dnd/hooks/useNavigationMenuItemDndKit.ts +++ b/packages/twenty-front/src/modules/navigation-menu-item/display/dnd/hooks/useNavigationMenuItemDndKit.ts @@ -1,7 +1,6 @@ -import { type DragDropProvider } from '@dnd-kit/react'; import { isSortable } from '@dnd-kit/react/sortable'; import { useStore } from 'jotai'; -import { type ComponentProps, useCallback, useState } from 'react'; +import { useCallback, useState } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { ADD_TO_NAV_SOURCE_DROPPABLE_ID } from '@/navigation-menu-item/common/constants/AddToNavSourceDroppableId'; @@ -23,22 +22,13 @@ import { resolveDropTarget } from '@/navigation-menu-item/display/dnd/utils/navi import { useNavigationMenuItemsData } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemsData'; import { useSortedNavigationMenuItems } from '@/navigation-menu-item/display/hooks/useSortedNavigationMenuItems'; import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemsDraftState'; +import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; +import { type DragDropProviderDragOverEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragOverEvent'; +import { type DragDropProviderDragStartEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragStartEvent'; -type DragStartPayload = Parameters< - NonNullable< - ComponentProps>['onDragStart'] - > ->[0]; -type DragOverPayload = Parameters< - NonNullable< - ComponentProps>['onDragOver'] - > ->[0]; -type DragEndPayload = Parameters< - NonNullable< - ComponentProps>['onDragEnd'] - > ->[0]; +type DragStartPayload = DragDropProviderDragStartEvent; +type DragOverPayload = DragDropProviderDragOverEvent; +type DragEndPayload = DragDropProviderDragEndEvent; export type NavigationMenuItemDndKitContextValues = { dragSource: { sourceDroppableId: string | null }; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/display/dnd/providers/NavigationMenuItemDndKitProvider.tsx b/packages/twenty-front/src/modules/navigation-menu-item/display/dnd/providers/NavigationMenuItemDndKitProvider.tsx index 72e60197b9..5dc864dbc7 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/display/dnd/providers/NavigationMenuItemDndKitProvider.tsx +++ b/packages/twenty-front/src/modules/navigation-menu-item/display/dnd/providers/NavigationMenuItemDndKitProvider.tsx @@ -7,6 +7,7 @@ import { NavigationDropTargetContext } from '@/navigation-menu-item/common/conte import { NavigationMenuItemDragContext } from '@/navigation-menu-item/common/contexts/NavigationMenuItemDragContext'; import type { DraggableData } from '@/navigation-menu-item/common/types/navigationMenuItemDndKitDraggableData'; import { useNavigationMenuItemDndKit } from '@/navigation-menu-item/display/dnd/hooks/useNavigationMenuItemDndKit'; +import { DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION } from '@/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation'; import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors'; type NavigationMenuItemDndKitProviderProps = { @@ -26,6 +27,7 @@ export const NavigationMenuItemDndKitProvider = ({ sensors={DND_KIT_SENSORS} + plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} onDragStart={handlers.onDragStart} onDragOver={handlers.onDragOver} onDragEnd={handlers.onDragEnd} diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard.ts b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard.ts index 442f1fc2d6..55c78790fd 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard.ts +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard.ts @@ -1,4 +1,4 @@ -import { type OnDragEndResponder } from '@hello-pangea/dnd'; +import { type DraggableListDropResult } from '@/ui/layout/draggable-list/types/DraggableListDropResult'; import { useCallback, useMemo } from 'react'; import { useColumnDefinitionsFromObjectMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromObjectMetadata'; @@ -92,8 +92,8 @@ export const useObjectOptionsForBoard = ({ [availableColumnDefinitions, recordIndexFieldDefinitionsByKey], ); - const handleReorderBoardFields: OnDragEndResponder = useCallback( - (result) => { + const handleReorderBoardFields = useCallback( + (result: DraggableListDropResult) => { if (!result.destination) { return; } diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useProcessOptionDropdownDragEnd.ts b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useProcessOptionDropdownDragEnd.ts index d6bfe17ac0..413f792690 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useProcessOptionDropdownDragEnd.ts +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useProcessOptionDropdownDragEnd.ts @@ -1,4 +1,4 @@ -import { type OnDragEndResponder } from '@hello-pangea/dnd'; +import { type DraggableListDropResult } from '@/ui/layout/draggable-list/types/DraggableListDropResult'; import { useReorderVisibleRecordFields } from '@/object-record/record-field/hooks/useReorderVisibleRecordFields'; @@ -12,8 +12,8 @@ export const useProcessOptionDropdownDragEnd = (recordTableId: string) => { const { saveViewFields } = useSaveCurrentViewFields(); - const processOptionDropdownDragEnd: OnDragEndResponder = useCallback( - async (result) => { + const processOptionDropdownDragEnd = useCallback( + async (result: DraggableListDropResult) => { if ( !result.destination || result.destination.index === 1 || diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext.ts b/packages/twenty-front/src/modules/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext.ts index 195efe7668..89d121a9e0 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext.ts +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext.ts @@ -1,7 +1,7 @@ import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem'; import { type ObjectOptionsContentId } from '@/object-record/object-options-dropdown/types/ObjectOptionsContentId'; import { type ViewType } from '@/views/types/ViewType'; -import { type OnDragEndResponder } from '@hello-pangea/dnd'; +import { type DraggableListDropResult } from '@/ui/layout/draggable-list/types/DraggableListDropResult'; import { createContext } from 'react'; export type ObjectOptionsDropdownContextValue = { @@ -12,7 +12,9 @@ export type ObjectOptionsDropdownContextValue = { onContentChange: (key: ObjectOptionsContentId) => void; resetContent: () => void; dropdownId: string; - handleRecordGroupOrderChangeWithModal?: OnDragEndResponder; + handleRecordGroupOrderChangeWithModal?: ( + result: DraggableListDropResult, + ) => void; }; export const ObjectOptionsDropdownContext = diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/hooks/useRecordBoardColumnDndKit.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/hooks/useRecordBoardColumnDndKit.ts index 69080e749b..453ae06c4e 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/hooks/useRecordBoardColumnDndKit.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/hooks/useRecordBoardColumnDndKit.ts @@ -1,5 +1,4 @@ -import { type DragDropProvider } from '@dnd-kit/react'; -import { type ComponentProps, useState } from 'react'; +import { useState } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { RECORD_GROUP_REORDER_CONFIRMATION_MODAL_ID } from '@/object-record/record-group/constants/RecordGroupReorderConfirmationModalId'; @@ -20,22 +19,13 @@ import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/ import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { ViewType } from '@/views/types/ViewType'; +import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; +import { type DragDropProviderDragMoveEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragMoveEvent'; +import { type DragDropProviderDragStartEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragStartEvent'; -type DragStartPayload = Parameters< - NonNullable< - ComponentProps>['onDragStart'] - > ->[0]; -type DragMovePayload = Parameters< - NonNullable< - ComponentProps>['onDragMove'] - > ->[0]; -type DragEndPayload = Parameters< - NonNullable< - ComponentProps>['onDragEnd'] - > ->[0]; +type DragStartPayload = DragDropProviderDragStartEvent; +type DragMovePayload = DragDropProviderDragMoveEvent; +type DragEndPayload = DragDropProviderDragEndEvent; type PendingReorder = { fromIndex: number; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/providers/RecordBoardColumnDndKitProvider.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/providers/RecordBoardColumnDndKitProvider.tsx index 3fd9e9b144..2d6dd6c5a3 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/providers/RecordBoardColumnDndKitProvider.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/providers/RecordBoardColumnDndKitProvider.tsx @@ -6,6 +6,7 @@ import { useRecordBoardColumnDndKit } from '@/object-record/record-board/record- import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { RecordGroupReorderConfirmationModal } from '@/object-record/record-group/components/RecordGroupReorderConfirmationModal'; import { DragDropItemDndContext } from '@/ui/utilities/drag-and-drop/context/DragDropItemDndContext'; +import { DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION } from '@/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation'; import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors'; import { type DragDropItemData } from '@/ui/utilities/drag-and-drop/types/DragDropItemData'; @@ -28,6 +29,7 @@ export const RecordBoardColumnDndKitProvider = ({ sensors={isRecordBoardViewSettingsReadOnly ? [] : DND_KIT_SENSORS} + plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} onDragStart={handlers.onDragStart} onDragMove={handlers.onDragMove} onDragEnd={handlers.onDragEnd} diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-dnd/hooks/useRecordBoardDndKit.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-dnd/hooks/useRecordBoardDndKit.ts index 33a438a06a..9005cce55a 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-dnd/hooks/useRecordBoardDndKit.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-dnd/hooks/useRecordBoardDndKit.ts @@ -1,6 +1,5 @@ -import { type DragDropProvider } from '@dnd-kit/react'; import { useStore } from 'jotai'; -import { type ComponentProps, useContext, useState } from 'react'; +import { useContext, useState } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; @@ -21,22 +20,13 @@ import { currentRecordSortsComponentState } from '@/object-record/record-sort/st import { useModal } from '@/ui/layout/modal/hooks/useModal'; import { useAtomComponentSelectorCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorCallbackState'; import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; +import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; +import { type DragDropProviderDragMoveEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragMoveEvent'; +import { type DragDropProviderDragStartEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragStartEvent'; -type DragStartPayload = Parameters< - NonNullable< - ComponentProps>['onDragStart'] - > ->[0]; -type DragMovePayload = Parameters< - NonNullable< - ComponentProps>['onDragMove'] - > ->[0]; -type DragEndPayload = Parameters< - NonNullable< - ComponentProps>['onDragEnd'] - > ->[0]; +type DragStartPayload = DragDropProviderDragStartEvent; +type DragMovePayload = DragDropProviderDragMoveEvent; +type DragEndPayload = DragDropProviderDragEndEvent; export type RecordBoardDndKitContextValues = { activeDropTargetIndex: number | null; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-dnd/providers/RecordBoardDndKitProvider.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-dnd/providers/RecordBoardDndKitProvider.tsx index 30463b6b34..a25decd8e6 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-dnd/providers/RecordBoardDndKitProvider.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-dnd/providers/RecordBoardDndKitProvider.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react'; import { RecordBoardCardDragOverlayContent } from '@/object-record/record-board/record-board-card/components/RecordBoardCardDragOverlayContent'; import { useRecordBoardDndKit } from '@/object-record/record-board/record-board-dnd/hooks/useRecordBoardDndKit'; +import { DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION } from '@/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation'; import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors'; import { DragDropItemDndContext } from '@/ui/utilities/drag-and-drop/context/DragDropItemDndContext'; import { type DragDropItemData } from '@/ui/utilities/drag-and-drop/types/DragDropItemData'; @@ -20,6 +21,7 @@ export const RecordBoardDndKitProvider = ({ sensors={DND_KIT_SENSORS} + plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} onDragStart={handlers.onDragStart} onDragMove={handlers.onDragMove} onDragEnd={handlers.onDragEnd} diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/month/components/RecordCalendarMonthDragDropContext.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/month/components/RecordCalendarMonthDragDropContext.tsx index 897e91d0a0..6ecb69ad15 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/month/components/RecordCalendarMonthDragDropContext.tsx +++ b/packages/twenty-front/src/modules/object-record/record-calendar/month/components/RecordCalendarMonthDragDropContext.tsx @@ -2,6 +2,7 @@ import { DragDropProvider } from '@dnd-kit/react'; import type { ReactNode } from 'react'; import { useRecordCalendarMonthDndKit } from '@/object-record/record-calendar/month/hooks/useRecordCalendarMonthDndKit'; +import { DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION } from '@/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation'; import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors'; import { DragDropItemDndContext } from '@/ui/utilities/drag-and-drop/context/DragDropItemDndContext'; import { type DragDropItemData } from '@/ui/utilities/drag-and-drop/types/DragDropItemData'; @@ -19,6 +20,7 @@ export const RecordCalendarMonthDragDropContext = ({ sensors={DND_KIT_SENSORS} + plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} onDragStart={handlers.onDragStart} onDragMove={handlers.onDragMove} onDragEnd={handlers.onDragEnd} diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/month/hooks/useRecordCalendarMonthDndKit.ts b/packages/twenty-front/src/modules/object-record/record-calendar/month/hooks/useRecordCalendarMonthDndKit.ts index fdd461e380..649957b596 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/month/hooks/useRecordCalendarMonthDndKit.ts +++ b/packages/twenty-front/src/modules/object-record/record-calendar/month/hooks/useRecordCalendarMonthDndKit.ts @@ -1,6 +1,5 @@ -import { type DragDropProvider } from '@dnd-kit/react'; import { useStore } from 'jotai'; -import { type ComponentProps, useState } from 'react'; +import { useState } from 'react'; import { Temporal } from 'temporal-polyfill'; import { isDefined } from 'twenty-shared/utils'; @@ -14,22 +13,13 @@ import { type DragDropItemData } from '@/ui/utilities/drag-and-drop/types/DragDr import { getDestinationIndex } from '@/ui/utilities/drag-and-drop/utils/getDestinationIndex'; import { resolveDropFromPointerY } from '@/ui/utilities/drag-and-drop/utils/resolveDropFromPointerY'; import { useAtomComponentFamilySelectorCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorCallbackState'; +import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; +import { type DragDropProviderDragMoveEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragMoveEvent'; +import { type DragDropProviderDragStartEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragStartEvent'; -type DragStartPayload = Parameters< - NonNullable< - ComponentProps>['onDragStart'] - > ->[0]; -type DragMovePayload = Parameters< - NonNullable< - ComponentProps>['onDragMove'] - > ->[0]; -type DragEndPayload = Parameters< - NonNullable< - ComponentProps>['onDragEnd'] - > ->[0]; +type DragStartPayload = DragDropProviderDragStartEvent; +type DragMovePayload = DragDropProviderDragMoveEvent; +type DragEndPayload = DragDropProviderDragEndEvent; export type RecordCalendarDndKitContextValues = { activeDropTargetIndex: number | null; diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/week/components/RecordCalendarWeekDragDropContext.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/week/components/RecordCalendarWeekDragDropContext.tsx index 44bd18c289..9c99a8bc3b 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/week/components/RecordCalendarWeekDragDropContext.tsx +++ b/packages/twenty-front/src/modules/object-record/record-calendar/week/components/RecordCalendarWeekDragDropContext.tsx @@ -2,33 +2,20 @@ import { useProcessRecordCalendarWeekEventDrop } from '@/object-record/record-ca import { type RecordCalendarWeekDndData } from '@/object-record/record-calendar/week/types/RecordCalendarWeekDndData'; import { resolveRecordCalendarWeekEventDrop } from '@/object-record/record-calendar/week/utils/resolveRecordCalendarWeekEventDrop'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION } from '@/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation'; import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors'; import { DragDropProvider } from '@dnd-kit/react'; import { t } from '@lingui/core/macro'; -import { - type ComponentProps, - type ReactNode, - type RefObject, - useState, -} from 'react'; +import { type ReactNode, type RefObject, useState } from 'react'; import type { Temporal } from 'temporal-polyfill'; import { isDefined } from 'twenty-shared/utils'; import { logError } from '~/utils/logError'; +import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; +import { type DragDropProviderDragStartEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragStartEvent'; -type DragStartPayload = Parameters< - NonNullable< - ComponentProps< - typeof DragDropProvider - >['onDragStart'] - > ->[0]; -type DragEndPayload = Parameters< - NonNullable< - ComponentProps< - typeof DragDropProvider - >['onDragEnd'] - > ->[0]; +type DragStartPayload = + DragDropProviderDragStartEvent; +type DragEndPayload = DragDropProviderDragEndEvent; type RecordCalendarWeekDragDropContextProps = { children: ReactNode; @@ -106,6 +93,7 @@ export const RecordCalendarWeekDragDropContext = ({ return ( sensors={DND_KIT_SENSORS} + plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} onDragStart={handleDragStart} onDragEnd={handleDragEnd} > diff --git a/packages/twenty-front/src/modules/object-record/record-drag/hooks/__tests__/useStartRecordDrag.test.tsx b/packages/twenty-front/src/modules/object-record/record-drag/hooks/__tests__/useStartRecordDrag.test.tsx index a68a29deb5..7e5a29de15 100644 --- a/packages/twenty-front/src/modules/object-record/record-drag/hooks/__tests__/useStartRecordDrag.test.tsx +++ b/packages/twenty-front/src/modules/object-record/record-drag/hooks/__tests__/useStartRecordDrag.test.tsx @@ -1,4 +1,3 @@ -import { type DragStart } from '@hello-pangea/dnd'; import { renderHook } from '@testing-library/react'; import { act } from 'react'; @@ -10,16 +9,6 @@ import { primaryDraggedRecordIdComponentState } from '@/object-record/record-dra import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper'; -const createDragStart = (draggableId: string, index: number): DragStart => ({ - draggableId, - type: 'record', - source: { - droppableId: 'test-droppable', - index, - }, - mode: 'FLUID', -}); - describe('useStartRecordDrag', () => { const Wrapper = getJestMetadataAndApolloMocksWrapper({}); @@ -52,11 +41,11 @@ describe('useStartRecordDrag', () => { { wrapper: Wrapper }, ); - const dragStart = createDragStart('record-1', 0); + const draggedRecordId = 'record-1'; const selectedRecordIds = ['record-2', 'record-3']; act(() => { - result.current.startRecordDrag(dragStart.draggableId, selectedRecordIds); + result.current.startRecordDrag(draggedRecordId, selectedRecordIds); }); expect(result.current.isMultiDragActive).toBe(true); @@ -94,11 +83,11 @@ describe('useStartRecordDrag', () => { { wrapper: Wrapper }, ); - const dragStart = createDragStart('record-1', 0); + const draggedRecordId = 'record-1'; const selectedRecordIds = ['record-1']; act(() => { - result.current.startRecordDrag(dragStart.draggableId, selectedRecordIds); + result.current.startRecordDrag(draggedRecordId, selectedRecordIds); }); expect(result.current.isMultiDragActive).toBe(true); @@ -136,11 +125,11 @@ describe('useStartRecordDrag', () => { { wrapper: Wrapper }, ); - const dragStart = createDragStart('record-2', 1); + const draggedRecordId = 'record-2'; const selectedRecordIds = ['record-1', 'record-2', 'record-3']; act(() => { - result.current.startRecordDrag(dragStart.draggableId, selectedRecordIds); + result.current.startRecordDrag(draggedRecordId, selectedRecordIds); }); expect(result.current.isMultiDragActive).toBe(true); @@ -186,11 +175,11 @@ describe('useStartRecordDrag', () => { { wrapper: Wrapper }, ); - const dragStart = createDragStart('record-1', 0); + const draggedRecordId = 'record-1'; const selectedRecordIds: string[] = []; act(() => { - result.current.startRecordDrag(dragStart.draggableId, selectedRecordIds); + result.current.startRecordDrag(draggedRecordId, selectedRecordIds); }); expect(result.current.isMultiDragActive).toBe(true); @@ -228,11 +217,11 @@ describe('useStartRecordDrag', () => { { wrapper: Wrapper }, ); - const dragStart = createDragStart('record-1', 0); + const draggedRecordId = 'record-1'; const selectedRecordIds = ['record-2', 'record-3']; act(() => { - result.current.startRecordDrag(dragStart.draggableId, selectedRecordIds); + result.current.startRecordDrag(draggedRecordId, selectedRecordIds); }); expect(result.current.isMultiDragActive).toBe(true); @@ -270,11 +259,11 @@ describe('useStartRecordDrag', () => { { wrapper: Wrapper }, ); - const dragStart = createDragStart('record-2', 1); + const draggedRecordId = 'record-2'; const selectedRecordIds = ['record-1', 'record-2', 'record-3']; act(() => { - result.current.startRecordDrag(dragStart.draggableId, selectedRecordIds); + result.current.startRecordDrag(draggedRecordId, selectedRecordIds); }); expect(result.current.isMultiDragActive).toBe(true); diff --git a/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessTableWithGroupRecordDrop.ts b/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessTableWithGroupRecordDrop.ts index 01c86ca5a4..9e50eb65bf 100644 --- a/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessTableWithGroupRecordDrop.ts +++ b/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessTableWithGroupRecordDrop.ts @@ -1,10 +1,10 @@ -import { type DropResult } from '@hello-pangea/dnd'; import { useStore } from 'jotai'; import { useCallback } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { isDraggingRecordComponentState } from '@/object-record/record-drag/states/isDraggingRecordComponentState'; +import { type RecordDragDropResult } from '@/object-record/record-drag/types/RecordDragDropResult'; import { originalDragSelectionComponentState } from '@/object-record/record-drag/states/originalDragSelectionComponentState'; import { processGroupDrop } from '@/object-record/record-drag/utils/processGroupDrop'; import { recordGroupDefinitionFamilyState } from '@/object-record/record-group/states/recordGroupDefinitionFamilyState'; @@ -61,7 +61,7 @@ export const useProcessTableWithGroupRecordDrop = () => { ); const processTableWithGroupRecordDrop = useCallback( - (result: DropResult) => { + (result: RecordDragDropResult) => { if (!result.destination) return; const destinationRecordGroupId = result.destination.droppableId; diff --git a/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessTableWithoutGroupRecordDrop.ts b/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessTableWithoutGroupRecordDrop.ts index 9c3b9434fa..8cb0a4eaf3 100644 --- a/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessTableWithoutGroupRecordDrop.ts +++ b/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessTableWithoutGroupRecordDrop.ts @@ -1,8 +1,7 @@ -import { type DropResult } from '@hello-pangea/dnd'; - import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { useTriggerTableWithoutGroupDragAndDropOptimisticUpdate } from '@/object-record/record-drag/hooks/useTriggerTableWithoutGroupDragAndDropOptimisticUpdate'; +import { type RecordDragDropResult } from '@/object-record/record-drag/types/RecordDragDropResult'; import { originalDragSelectionComponentState } from '@/object-record/record-drag/states/originalDragSelectionComponentState'; import { getDragOperationType } from '@/object-record/record-drag/utils/getDragOperationType'; import { processMultiDrag } from '@/object-record/record-drag/utils/processMultiDrag'; @@ -54,7 +53,7 @@ export const useProcessTableWithoutGroupRecordDrop = () => { useTriggerTableWithoutGroupDragAndDropOptimisticUpdate(); const processTableWithoutGroupRecordDrop = useCallback( - async (tableRecordDropResult: DropResult) => { + async (tableRecordDropResult: RecordDragDropResult) => { if (!tableRecordDropResult.destination) return; if (currentRecordSorts.length > 0) { diff --git a/packages/twenty-front/src/modules/object-record/record-drag/types/RecordDragDropResult.ts b/packages/twenty-front/src/modules/object-record/record-drag/types/RecordDragDropResult.ts new file mode 100644 index 0000000000..c67b07f97f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-drag/types/RecordDragDropResult.ts @@ -0,0 +1,11 @@ +export type RecordDragDropResult = { + draggableId: string; + source: { + droppableId: string; + index: number; + }; + destination: { + droppableId: string; + index: number; + } | null; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupsVisibilityDropdownSection.tsx b/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupsVisibilityDropdownSection.tsx index 3011f47f33..730b383101 100644 --- a/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupsVisibilityDropdownSection.tsx +++ b/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupsVisibilityDropdownSection.tsx @@ -1,8 +1,4 @@ -import { - type DropResult, - type OnDragEndResponder, - type ResponderProvided, -} from '@hello-pangea/dnd'; +import { type DraggableListDropResult } from '@/ui/layout/draggable-list/types/DraggableListDropResult'; import { useRef } from 'react'; import { RecordGroupMenuItemDraggable } from '@/object-record/record-group/components/RecordGroupMenuItemDraggable'; @@ -15,7 +11,7 @@ import { StyledDropdownMenuSubheader } from '@/ui/layout/dropdown/components/Sty type RecordGroupsVisibilityDropdownSectionProps = { recordGroupIds: string[]; isDraggable: boolean; - onDragEnd?: OnDragEndResponder; + onDragEnd?: (result: DraggableListDropResult) => void; onVisibilityChange: (recordGroup: RecordGroupDefinition) => void; title: string; showSubheader?: boolean; @@ -33,8 +29,8 @@ export const RecordGroupsVisibilityDropdownSection = ({ showDragGrip, isVisibleLimitReached = false, }: RecordGroupsVisibilityDropdownSectionProps) => { - const handleOnDrag = (result: DropResult, provided: ResponderProvided) => { - onDragEnd?.(result, provided); + const handleOnDrag = (result: DraggableListDropResult) => { + onDragEnd?.(result); }; const ref = useRef(null); diff --git a/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupReorderConfirmationModal.ts b/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupReorderConfirmationModal.ts index b5ee2eca70..59f59e6daa 100644 --- a/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupReorderConfirmationModal.ts +++ b/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupReorderConfirmationModal.ts @@ -10,7 +10,7 @@ import { useModal } from '@/ui/layout/modal/hooks/useModal'; import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState'; import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; import { type ViewType } from '@/views/types/ViewType'; -import { type OnDragEndResponder } from '@hello-pangea/dnd'; +import { type DraggableListDropResult } from '@/ui/layout/draggable-list/types/DraggableListDropResult'; import { useState } from 'react'; type UseRecordGroupReorderConfirmationModalParams = { @@ -30,14 +30,14 @@ export const useRecordGroupReorderConfirmationModal = ({ const { openModal } = useModal(); const [pendingDragEndHandlerParams, setPendingDragEndHandlerParams] = - useState | null>(null); + useState(null); const { reorderRecordGroups } = useReorderRecordGroups({ recordIndexId, viewType, }); - const handleDragEnd: OnDragEndResponder = (result) => { + const handleDragEnd = (result: DraggableListDropResult) => { if (!result.destination) { return; } @@ -57,14 +57,14 @@ export const useRecordGroupReorderConfirmationModal = ({ ); const { closeAnyOpenDropdown } = useCloseAnyOpenDropdown(); - const handleDragEndWithModal: OnDragEndResponder = (result, provided) => { + const handleDragEndWithModal = (result: DraggableListDropResult) => { if (!isDragableSortRecordGroup) { closeAnyOpenDropdown(); openModal(RECORD_GROUP_REORDER_CONFIRMATION_MODAL_ID); setActiveDropdownFocusIdAndMemorizePrevious(null); - setPendingDragEndHandlerParams([result, provided]); + setPendingDragEndHandlerParams(result); } else { - handleDragEnd(result, provided); + handleDragEnd(result); } }; @@ -75,7 +75,7 @@ export const useRecordGroupReorderConfirmationModal = ({ setRecordIndexRecordGroupSort(RecordGroupSort.Manual); setPendingDragEndHandlerParams(null); - handleDragEnd(...pendingDragEndHandlerParams); + handleDragEnd(pendingDragEndHandlerParams); goBackToPreviousDropdownFocusId(); }; diff --git a/packages/twenty-front/src/modules/object-record/record-group/states/recordGroupPendingDragEndReorderState.ts b/packages/twenty-front/src/modules/object-record/record-group/states/recordGroupPendingDragEndReorderState.ts deleted file mode 100644 index 10d3a4b13c..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-group/states/recordGroupPendingDragEndReorderState.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { type OnDragEndResponder } from '@hello-pangea/dnd'; - -import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; - -export const recordGroupPendingDragEndReorderState = - createAtomState | null>({ - key: 'recordGroupPendingDragEndReorderState', - defaultValue: null, - }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableNoRecordGroupRows.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableNoRecordGroupRows.tsx index af71daa192..95f3e9f59c 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableNoRecordGroupRows.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableNoRecordGroupRows.tsx @@ -1,9 +1,12 @@ import { RecordTableNoRecordGroupAddNew } from '@/object-record/record-table/components/RecordTableNoRecordGroupAddNew'; +import { RECORD_TABLE_NO_RECORD_GROUP_DROPPABLE_ID } from '@/object-record/record-table/constants/RecordTableNoRecordGroupDroppableId'; +import { RECORD_TABLE_ROW_DND_TYPE } from '@/object-record/record-table/constants/RecordTableRowDndType'; import { RecordTableRowVirtualizedContainer } from '@/object-record/record-table/virtualization/components/RecordTableRowVirtualizedContainer'; import { RecordTableVirtualizedBodyPlaceholder } from '@/object-record/record-table/virtualization/components/RecordTableVirtualizedBodyPlaceholder'; import { RecordTableVirtualizedDebugHelper } from '@/object-record/record-table/virtualization/components/RecordTableVirtualizedDebugHelper'; import { NUMBER_OF_VIRTUALIZED_ROWS } from '@/object-record/record-table/virtualization/constants/NumberOfVirtualizedRows'; import { totalNumberOfRecordsToVirtualizeComponentState } from '@/object-record/record-table/virtualization/states/totalNumberOfRecordsToVirtualizeComponentState'; +import { DragDropItemEndDropZone } from '@/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { styled } from '@linaria/react'; import { getContiguousIncrementalValues } from 'twenty-shared/utils'; @@ -38,7 +41,16 @@ export const RecordTableNoRecordGroupRows = () => { /> ); })} - + + + ); diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRecordGroupRows.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRecordGroupRows.tsx index 7d122c557d..49106982ce 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRecordGroupRows.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRecordGroupRows.tsx @@ -2,17 +2,23 @@ import { useCurrentRecordGroupId } from '@/object-record/record-group/hooks/useC import { useShouldHideRecordGroup } from '@/object-record/record-group/hooks/useShouldHideRecordGroup'; import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState'; import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector'; -import { RecordTableBodyDroppablePlaceholder } from '@/object-record/record-table/record-table-body/components/RecordTableBodyDroppablePlaceholder'; +import { RECORD_TABLE_ROW_DND_TYPE } from '@/object-record/record-table/constants/RecordTableRowDndType'; import { RecordTableAggregateFooter } from '@/object-record/record-table/record-table-footer/components/RecordTableAggregateFooter'; import { RecordTableRow } from '@/object-record/record-table/record-table-row/components/RecordTableRow'; import { RecordTableRecordGroupSectionAddNew } from '@/object-record/record-table/record-table-section/components/RecordTableRecordGroupSectionAddNew'; import { RecordTableRecordGroupSectionLoadMore } from '@/object-record/record-table/record-table-section/components/RecordTableRecordGroupSectionLoadMore'; import { isRecordGroupTableSectionToggledComponentState } from '@/object-record/record-table/record-table-section/states/isRecordGroupTableSectionToggledComponentState'; +import { DragDropItemEndDropZone } from '@/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone'; import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue'; import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; +import { styled } from '@linaria/react'; import { useMemo } from 'react'; import { isDefined } from 'twenty-shared/utils'; +const StyledRecordGroupEndDropZone = styled(DragDropItemEndDropZone)` + width: 100%; +`; + export const RecordTableRecordGroupRows = () => { const currentRecordGroupId = useCurrentRecordGroupId(); @@ -63,9 +69,17 @@ export const RecordTableRecordGroupRows = () => { /> ); })} - - - + + + + { - const { droppablePlaceholder } = useRecordTableBodyDroppableContextOrThrow(); - - return droppablePlaceholder; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyLoading.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyLoading.tsx index 0d46b1535a..5b8afd9f04 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyLoading.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyLoading.tsx @@ -38,7 +38,6 @@ export const RecordTableBodyLoading = () => { > diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyNoRecordGroupDragDropContextProvider.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyNoRecordGroupDragDropContextProvider.tsx index b13a609344..959e639e62 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyNoRecordGroupDragDropContextProvider.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyNoRecordGroupDragDropContextProvider.tsx @@ -1,19 +1,23 @@ -import { - DragDropContext, - type DragStart, - type DropResult, -} from '@hello-pangea/dnd'; -import { type ReactNode, useCallback } from 'react'; - -import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; -import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; -import { useAtomComponentSelectorCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorCallbackState'; +import { DragDropProvider, DragOverlay } from '@dnd-kit/react'; import { useStore } from 'jotai'; +import { type ReactNode, useCallback } from 'react'; +import { isDefined } from 'twenty-shared/utils'; import { useEndRecordDrag } from '@/object-record/record-drag/hooks/useEndRecordDrag'; import { useProcessTableWithoutGroupRecordDrop } from '@/object-record/record-drag/hooks/useProcessTableWithoutGroupRecordDrop'; import { useStartRecordDrag } from '@/object-record/record-drag/hooks/useStartRecordDrag'; +import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; +import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; +import { RecordTableRowDragOverlayContent } from '@/object-record/record-table/record-table-row/components/RecordTableRowDragOverlayContent'; import { selectedRowIdsComponentSelector } from '@/object-record/record-table/states/selectors/selectedRowIdsComponentSelector'; +import { type RecordTableRowDragData } from '@/object-record/record-table/types/RecordTableRowDragData'; +import { DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION } from '@/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation'; +import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors'; +import { type DragDropItemData } from '@/ui/utilities/drag-and-drop/types/DragDropItemData'; +import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; +import { type DragDropProviderDragStartEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragStartEvent'; +import { getDestinationIndex } from '@/ui/utilities/drag-and-drop/utils/getDestinationIndex'; +import { useAtomComponentSelectorCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorCallbackState'; export const RecordTableBodyNoRecordGroupDragDropContextProvider = ({ children, @@ -36,25 +40,83 @@ export const RecordTableBodyNoRecordGroupDragDropContextProvider = ({ useProcessTableWithoutGroupRecordDrop(); const handleDragStart = useCallback( - (start: DragStart) => { + (event: DragDropProviderDragStartEvent) => { + const source = event.operation.source; + const sourceData = source?.data as RecordTableRowDragData | undefined; + + if (!isDefined(source) || !isDefined(sourceData)) { + return; + } + const currentSelectedRecordIds = store.get(selectedRowIds) as string[]; - startRecordDrag(start.draggableId, currentSelectedRecordIds); + startRecordDrag(sourceData.recordId, currentSelectedRecordIds); }, [selectedRowIds, startRecordDrag, store], ); const handleDragEnd = useCallback( - (result: DropResult) => { - processTableWithoutGroupRecordDrop(result); - endRecordDrag(); + (event: DragDropProviderDragEndEvent) => { + const source = event.operation.source; + const sourceData = source?.data as RecordTableRowDragData | undefined; + const targetData = event.operation.target?.data as + | DragDropItemData + | undefined; + + if ( + event.canceled || + !isDefined(source) || + !isDefined(sourceData) || + !isDefined(targetData) + ) { + endRecordDrag(); + return; + } + + // Row targets and end drop zones mark the gap before them; convert that + // gap into the index the dragged row will occupy after the move. + const destinationIndex = getDestinationIndex({ + dropTargetIndex: targetData.index, + sourceIndex: sourceData.index, + sourceDroppableId: sourceData.droppableId, + destinationDroppableId: targetData.droppableId, + }); + + if (destinationIndex === sourceData.index) { + endRecordDrag(); + return; + } + + try { + processTableWithoutGroupRecordDrop({ + draggableId: sourceData.recordId, + source: { + droppableId: sourceData.droppableId, + index: sourceData.index, + }, + destination: { + droppableId: targetData.droppableId, + index: destinationIndex, + }, + }); + } finally { + endRecordDrag(); + } }, [endRecordDrag, processTableWithoutGroupRecordDrop], ); return ( - + + sensors={DND_KIT_SENSORS} + plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} + onDragStart={handleDragStart} + onDragEnd={handleDragEnd} + > {children} - + + {(source) => } + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyNoRecordGroupDroppable.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyNoRecordGroupDroppable.tsx deleted file mode 100644 index ebcc5c7f13..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyNoRecordGroupDroppable.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { RecordTableBody } from '@/object-record/record-table/record-table-body/components/RecordTableBody'; -import { RecordTableBodyVirtualizedDraggableClone } from '@/object-record/record-table/record-table-body/components/RecordTableBodyVirtualizedDraggableClone'; -import { RecordTableBodyDroppableContextProvider } from '@/object-record/record-table/record-table-body/contexts/RecordTableBodyDroppableContext'; -import { Droppable } from '@hello-pangea/dnd'; -import { type ReactNode, useState } from 'react'; -import { v4 } from 'uuid'; - -type RecordTableBodyNoRecordGroupDroppableProps = { - children: ReactNode; - isDropDisabled?: boolean; -}; - -export const RecordTableBodyNoRecordGroupDroppable = ({ - children, - isDropDisabled, -}: RecordTableBodyNoRecordGroupDroppableProps) => { - const [v4Persistable] = useState(v4()); - - return ( - ( - - )} - > - {(provided) => ( - - - {children} - - - )} - - ); -}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDragDropContextProvider.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDragDropContextProvider.tsx index 729128a18d..1f7aa44582 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDragDropContextProvider.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDragDropContextProvider.tsx @@ -1,17 +1,23 @@ +import { DragDropProvider, DragOverlay } from '@dnd-kit/react'; +import { useStore } from 'jotai'; +import { type ReactNode, useCallback } from 'react'; +import { isDefined } from 'twenty-shared/utils'; + import { useEndRecordDrag } from '@/object-record/record-drag/hooks/useEndRecordDrag'; import { useProcessTableWithGroupRecordDrop } from '@/object-record/record-drag/hooks/useProcessTableWithGroupRecordDrop'; import { useStartRecordDrag } from '@/object-record/record-drag/hooks/useStartRecordDrag'; import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; +import { RecordTableRowDragOverlayContent } from '@/object-record/record-table/record-table-row/components/RecordTableRowDragOverlayContent'; import { selectedRowIdsComponentSelector } from '@/object-record/record-table/states/selectors/selectedRowIdsComponentSelector'; +import { type RecordTableRowDragData } from '@/object-record/record-table/types/RecordTableRowDragData'; +import { DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION } from '@/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation'; +import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors'; +import { type DragDropItemData } from '@/ui/utilities/drag-and-drop/types/DragDropItemData'; +import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; +import { type DragDropProviderDragStartEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragStartEvent'; +import { getDestinationIndex } from '@/ui/utilities/drag-and-drop/utils/getDestinationIndex'; import { useAtomComponentSelectorCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorCallbackState'; -import { useStore } from 'jotai'; -import { - DragDropContext, - type DragStart, - type DropResult, -} from '@hello-pangea/dnd'; -import { type ReactNode, useCallback } from 'react'; export const RecordTableBodyRecordGroupDragDropContextProvider = ({ children, @@ -35,26 +41,86 @@ export const RecordTableBodyRecordGroupDragDropContextProvider = ({ useProcessTableWithGroupRecordDrop(); const handleDragStart = useCallback( - (start: DragStart) => { + (event: DragDropProviderDragStartEvent) => { + const source = event.operation.source; + const sourceData = source?.data as RecordTableRowDragData | undefined; + + if (!isDefined(source) || !isDefined(sourceData)) { + return; + } + const currentSelectedRecordIds = store.get(selectedRowIds) as string[]; - startRecordDrag(start.draggableId, currentSelectedRecordIds); + startRecordDrag(sourceData.recordId, currentSelectedRecordIds); }, [selectedRowIds, startRecordDrag, store], ); const handleDragEnd = useCallback( - (result: DropResult) => { - processTableWithGroupRecordDrop(result); + (event: DragDropProviderDragEndEvent) => { + const source = event.operation.source; + const sourceData = source?.data as RecordTableRowDragData | undefined; + const targetData = event.operation.target?.data as + | DragDropItemData + | undefined; - endRecordDrag(); + if ( + event.canceled || + !isDefined(source) || + !isDefined(sourceData) || + !isDefined(targetData) + ) { + endRecordDrag(); + return; + } + + // Row targets and end drop zones mark the gap before them; convert that + // gap into the index the dragged row will occupy after the move. + const destinationIndex = getDestinationIndex({ + dropTargetIndex: targetData.index, + sourceIndex: sourceData.index, + sourceDroppableId: sourceData.droppableId, + destinationDroppableId: targetData.droppableId, + }); + + const isSameRecordGroup = + sourceData.droppableId === targetData.droppableId; + + if (isSameRecordGroup && destinationIndex === sourceData.index) { + endRecordDrag(); + return; + } + + try { + processTableWithGroupRecordDrop({ + draggableId: sourceData.recordId, + source: { + droppableId: sourceData.droppableId, + index: sourceData.index, + }, + destination: { + droppableId: targetData.droppableId, + index: destinationIndex, + }, + }); + } finally { + endRecordDrag(); + } }, [endRecordDrag, processTableWithGroupRecordDrop], ); return ( - + + sensors={DND_KIT_SENSORS} + plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} + onDragStart={handleDragStart} + onDragEnd={handleDragEnd} + > {children} - + + {(source) => } + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDroppable.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDroppable.tsx deleted file mode 100644 index 0ba70f0705..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDroppable.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { RecordTableBody } from '@/object-record/record-table/record-table-body/components/RecordTableBody'; -import { RecordTableBodyDroppableContextProvider } from '@/object-record/record-table/record-table-body/contexts/RecordTableBodyDroppableContext'; -import { Droppable } from '@hello-pangea/dnd'; -import { type ReactNode } from 'react'; - -type RecordTableBodyRecordGroupDroppableProps = { - children: ReactNode; - recordGroupId: string; - isDropDisabled?: boolean; -}; - -export const RecordTableBodyRecordGroupDroppable = ({ - children, - recordGroupId, - isDropDisabled, -}: RecordTableBodyRecordGroupDroppableProps) => { - return ( - - {(provided) => ( - - - {children} - - - )} - - ); -}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableNoRecordGroupBody.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableNoRecordGroupBody.tsx index afae3713ce..870a573113 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableNoRecordGroupBody.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableNoRecordGroupBody.tsx @@ -2,9 +2,9 @@ import { recordIndexHasRecordsComponentSelector } from '@/object-record/record-i import { RecordTableNoRecordGroupBodyContextProvider } from '@/object-record/record-table/components/RecordTableNoRecordGroupBodyContextProvider'; import { RecordTableNoRecordGroupRows } from '@/object-record/record-table/components/RecordTableNoRecordGroupRows'; +import { RecordTableBody } from '@/object-record/record-table/record-table-body/components/RecordTableBody'; import { RecordTableBodyLoading } from '@/object-record/record-table/record-table-body/components/RecordTableBodyLoading'; import { RecordTableBodyNoRecordGroupDragDropContextProvider } from '@/object-record/record-table/record-table-body/components/RecordTableBodyNoRecordGroupDragDropContextProvider'; -import { RecordTableBodyNoRecordGroupDroppable } from '@/object-record/record-table/record-table-body/components/RecordTableBodyNoRecordGroupDroppable'; import { RecordTableCellPortals } from '@/object-record/record-table/record-table-cell/components/RecordTableCellPortals'; import { RecordTableAggregateFooter } from '@/object-record/record-table/record-table-footer/components/RecordTableAggregateFooter'; import { isRecordTableInitialLoadingComponentState } from '@/object-record/record-table/states/isRecordTableInitialLoadingComponentState'; @@ -30,10 +30,10 @@ export const RecordTableNoRecordGroupBody = () => { return ( - + - + {!isRecordTableInitialLoading && recordTableHasRecords && ( )} diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupsBody.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupsBody.tsx index b584e4a526..4247ab3228 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupsBody.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupsBody.tsx @@ -4,9 +4,9 @@ import { RecordIndexGroupAggregatesDataLoader } from '@/object-record/record-ind import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector'; import { RecordTableRecordGroupBodyContextProvider } from '@/object-record/record-table/components/RecordTableRecordGroupBodyContextProvider'; import { RecordTableRecordGroupRows } from '@/object-record/record-table/components/RecordTableRecordGroupRows'; +import { RecordTableBody } from '@/object-record/record-table/record-table-body/components/RecordTableBody'; import { RecordTableBodyLoading } from '@/object-record/record-table/record-table-body/components/RecordTableBodyLoading'; import { RecordTableBodyRecordGroupDragDropContextProvider } from '@/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDragDropContextProvider'; -import { RecordTableBodyRecordGroupDroppable } from '@/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDroppable'; import { RecordTableCellPortals } from '@/object-record/record-table/record-table-cell/components/RecordTableCellPortals'; import { RecordTableRecordGroupAddNewGroup } from '@/object-record/record-table/record-table-section/components/RecordTableRecordGroupAddNewGroup'; import { RecordTableRecordGroupSection } from '@/object-record/record-table/record-table-section/components/RecordTableRecordGroupSection'; @@ -43,13 +43,11 @@ export const RecordTableRecordGroupsBody = () => { recordGroupId={recordGroupId} > - + {index === 0 && } - + ))} diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/contexts/RecordTableBodyDroppableContext.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/contexts/RecordTableBodyDroppableContext.ts deleted file mode 100644 index 06dd1be1c4..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/contexts/RecordTableBodyDroppableContext.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { type ReactNode } from 'react'; -import { createRequiredContext } from '~/utils/createRequiredContext'; - -export type RecordTableBodyDroppableContextValue = { - droppablePlaceholder: ReactNode; -}; - -export const [ - RecordTableBodyDroppableContextProvider, - useRecordTableBodyDroppableContextOrThrow, -] = createRequiredContext( - 'RecordTableBodyDroppableContext', -); diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellDragAndDrop.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellDragAndDrop.tsx index 4644b26f00..79c6ab9c0b 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellDragAndDrop.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellDragAndDrop.tsx @@ -1,4 +1,5 @@ import { styled } from '@linaria/react'; +import { useContext } from 'react'; import { RECORD_TABLE_COLUMN_DRAG_AND_DROP_WIDTH_CLASS_NAME } from '@/object-record/record-table/constants/RecordTableColumnDragAndDropWidthClassName'; import { themeCssVariables } from 'twenty-ui/theme-constants'; @@ -6,6 +7,7 @@ import { RECORD_TABLE_ROW_HEIGHT } from '@/object-record/record-table/constants/ import { TABLE_Z_INDEX } from '@/object-record/record-table/constants/TableZIndex'; import { useRecordTableRowDraggableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableRowDraggableContext'; import { RecordTableCellStyleWrapper } from '@/object-record/record-table/record-table-cell/components/RecordTableCellStyleWrapper'; +import { DragDropItemSortableHandleRefContext } from '@/ui/utilities/drag-and-drop/context/DragDropItemSortableHandleRefContext'; import { IconListViewGrip } from 'twenty-ui/input'; const StyledContainer = styled.div` @@ -30,19 +32,18 @@ const StyledIconWrapper = styled.div<{ isDragging: boolean }>` `; export const RecordTableCellDragAndDrop = () => { - const { dragHandleProps, isDragging } = - useRecordTableRowDraggableContextOrThrow(); + const { isDragging } = useRecordTableRowDraggableContextOrThrow(); + + const sortableHandleRef = useContext(DragDropItemSortableHandleRefContext); return ( - + diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellFirstRowFirstColumn.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellFirstRowFirstColumn.tsx index e70b8dcc45..6bb7ae50fd 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellFirstRowFirstColumn.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellFirstRowFirstColumn.tsx @@ -1,6 +1,5 @@ import { TABLE_Z_INDEX } from '@/object-record/record-table/constants/TableZIndex'; import { getRecordTableColumnFieldWidthClassName } from '@/object-record/record-table/utils/getRecordTableColumnFieldWidthClassName'; -import { type DraggableProvidedDragHandleProps } from '@hello-pangea/dnd'; import { cx } from '@linaria/core'; import { styled } from '@linaria/react'; import { useContext, type ReactNode } from 'react'; @@ -39,15 +38,13 @@ export const RecordTableCellFirstRowFirstColumn = ({ isDragging, hasRightBorder = true, hasBottomBorder = true, - ...dragHandleProps }: { - className?: string; children?: ReactNode; isSelected?: boolean; isDragging?: boolean; hasRightBorder?: boolean; hasBottomBorder?: boolean; -} & (Partial | null)) => { +}) => { const { theme } = useContext(ThemeContext); const zIndex = TABLE_Z_INDEX.cell.sticky; @@ -69,8 +66,6 @@ export const RecordTableCellFirstRowFirstColumn = ({ hasRightBorder={hasRightBorder} hasBottomBorder={hasBottomBorder} zIndex={zIndex} - // oxlint-disable-next-line react/jsx-props-no-spreading - {...dragHandleProps} className={cx( 'table-cell-0-0', getRecordTableColumnFieldWidthClassName(0), diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellStyleWrapper.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellStyleWrapper.tsx index 6010329e53..ebb0993937 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellStyleWrapper.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellStyleWrapper.tsx @@ -1,4 +1,3 @@ -import { type DraggableProvidedDragHandleProps } from '@hello-pangea/dnd'; import { cx } from '@linaria/core'; import { styled } from '@linaria/react'; import { type ReactNode, useContext } from 'react'; @@ -35,7 +34,7 @@ export const RecordTableCellStyleWrapper = ({ hasRightBorder = true, hasBottomBorder = true, widthClassName, - ...dragHandleProps + ...divProps }: { className?: string; children?: ReactNode; @@ -44,7 +43,7 @@ export const RecordTableCellStyleWrapper = ({ hasRightBorder?: boolean; hasBottomBorder?: boolean; widthClassName: string; -} & (Partial | null)) => { +} & React.ComponentProps<'div'>) => { const { theme } = useContext(ThemeContext); const tdBackgroundColor = isSelected @@ -64,7 +63,7 @@ export const RecordTableCellStyleWrapper = ({ hasRightBorder={hasRightBorder} hasBottomBorder={hasBottomBorder} // oxlint-disable-next-line react/jsx-props-no-spreading - {...dragHandleProps} + {...divProps} className={cx('table-cell', widthClassName)} > {children} diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__mocks__/cell.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__mocks__/cell.ts index af8729f4cd..4bc7e39500 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__mocks__/cell.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__mocks__/cell.ts @@ -12,7 +12,6 @@ export const recordTableRowContextValue: RecordTableRowContextValue = { export const recordTableRowDraggableContextValue: RecordTableRowDraggableContextValue = { - dragHandleProps: {} as any, isDragging: false, }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/hooks/useRecordTableHeaderDndKit.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/hooks/useRecordTableHeaderDndKit.ts index 021edcfce4..7e76f7a1a2 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/hooks/useRecordTableHeaderDndKit.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/hooks/useRecordTableHeaderDndKit.ts @@ -1,5 +1,4 @@ -import { type DragDropProvider } from '@dnd-kit/react'; -import { type ComponentProps, useState } from 'react'; +import { useState } from 'react'; import { filterOutByProperty, isDefined } from 'twenty-shared/utils'; import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; @@ -16,22 +15,13 @@ import { isRecordTableDragColumnHiddenComponentState } from '@/object-record/rec import { type DragDropItemData } from '@/ui/utilities/drag-and-drop/types/DragDropItemData'; import { resolveDragDropItemDrop } from '@/ui/utilities/drag-and-drop/utils/resolveDragDropItemDrop'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; +import { type DragDropProviderDragMoveEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragMoveEvent'; +import { type DragDropProviderDragStartEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragStartEvent'; -type DragStartPayload = Parameters< - NonNullable< - ComponentProps>['onDragStart'] - > ->[0]; -type DragMovePayload = Parameters< - NonNullable< - ComponentProps>['onDragMove'] - > ->[0]; -type DragEndPayload = Parameters< - NonNullable< - ComponentProps>['onDragEnd'] - > ->[0]; +type DragStartPayload = DragDropProviderDragStartEvent; +type DragMovePayload = DragDropProviderDragMoveEvent; +type DragEndPayload = DragDropProviderDragEndEvent; export type RecordTableHeaderDndKitContextValues = { activeDropTargetIndex: number | null; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/providers/RecordTableHeaderDndKitProvider.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/providers/RecordTableHeaderDndKitProvider.tsx index ab2f5e92ff..2bde9d8446 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/providers/RecordTableHeaderDndKitProvider.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/providers/RecordTableHeaderDndKitProvider.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react'; import { useRecordTableHeaderDndKit } from '@/object-record/record-table/record-table-header/dnd/hooks/useRecordTableHeaderDndKit'; import { DragDropItemDndContext } from '@/ui/utilities/drag-and-drop/context/DragDropItemDndContext'; +import { DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION } from '@/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation'; import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors'; import { type DragDropItemData } from '@/ui/utilities/drag-and-drop/types/DragDropItemData'; @@ -19,6 +20,7 @@ export const RecordTableHeaderDndKitProvider = ({ sensors={DND_KIT_SENSORS} + plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} onDragStart={handlers.onDragStart} onDragMove={handlers.onDragMove} onDragEnd={handlers.onDragEnd} diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableDraggableTr.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableDraggableTr.tsx index 5cb4fae6b9..df8d6ab035 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableDraggableTr.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableDraggableTr.tsx @@ -1,11 +1,29 @@ -import { Draggable } from '@hello-pangea/dnd'; -import { type ReactNode, useContext } from 'react'; +import { useSortable } from '@dnd-kit/react/sortable'; +import { styled } from '@linaria/react'; +import { type ReactNode, useContext, useState } from 'react'; +import { isDefined } from 'twenty-shared/utils'; import { ThemeContext } from 'twenty-ui/theme-constants'; +import { v4 } from 'uuid'; +import { RecordGroupContext } from '@/object-record/record-group/states/context/RecordGroupContext'; +import { RECORD_TABLE_NO_RECORD_GROUP_DROPPABLE_ID } from '@/object-record/record-table/constants/RecordTableNoRecordGroupDroppableId'; +import { RECORD_TABLE_ROW_DND_TYPE } from '@/object-record/record-table/constants/RecordTableRowDndType'; +import { TABLE_Z_INDEX } from '@/object-record/record-table/constants/TableZIndex'; import { RecordTableRowDraggableContextProvider } from '@/object-record/record-table/contexts/RecordTableRowDraggableContext'; import { RecordTableRowMultiDragPreview } from '@/object-record/record-table/record-table-row/components/RecordTableRowMultiDragPreview'; import { RecordTableTr } from '@/object-record/record-table/record-table-row/components/RecordTableTr'; import { useIsTableRowSecondaryDragged } from '@/object-record/record-table/record-table-row/hooks/useIsRecordSecondaryDragged'; +import { type RecordTableRowDragData } from '@/object-record/record-table/types/RecordTableRowDragData'; +import { DragDropItemDropLine } from '@/ui/utilities/drag-and-drop/components/DragDropItemDropLine'; +import { DND_KIT_PLUGINS_WITHOUT_OPTIMISTIC } from '@/ui/utilities/drag-and-drop/constants/DndKitPluginsWithoutOptimistic'; +import { DragDropItemSortableHandleRefContext } from '@/ui/utilities/drag-and-drop/context/DragDropItemSortableHandleRefContext'; + +// The grip, checkbox and first field cells are sticky at +// TABLE_Z_INDEX.cell.sticky; without a higher z-index they would paint over +// the insertion line and truncate it to the scrollable columns. +const StyledRowDropLine = styled(DragDropItemDropLine)` + z-index: ${TABLE_Z_INDEX.rowDropLine}; +`; type RecordTableDraggableTrProps = { className?: string; @@ -30,49 +48,65 @@ export const RecordTableDraggableTr = ({ const { isSecondaryDragged } = useIsTableRowSecondaryDragged(recordId); + const { recordGroupId } = useContext(RecordGroupContext); + + const droppableId = isDefined(recordGroupId) + ? recordGroupId + : RECORD_TABLE_NO_RECORD_GROUP_DROPPABLE_ID; + + const rowDragData: RecordTableRowDragData = { + droppableId, + index: draggableIndex, + recordId, + focusIndex, + }; + + // The sortable id must never change in place: when the virtualization + // treadmill shifts recordIds across mounted rows after a reorder, dnd-kit + // re-registers each row under its new id and disposes the row that + // previously held it, leaving one row permanently undraggable. A stable + // per-instance id avoids the collision; recordId travels in the drag data. + const [sortableId] = useState(() => v4()); + + const { handleRef, ref, isDragging, isDragSource, isDropTarget } = + useSortable({ + id: sortableId, + index: draggableIndex, + group: droppableId, + type: RECORD_TABLE_ROW_DND_TYPE, + accept: RECORD_TABLE_ROW_DND_TYPE, + data: rowDragData, + disabled: isDragDisabled, + transition: null, + plugins: DND_KIT_PLUGINS_WITHOUT_OPTIMISTIC, + feedback: 'clone', + }); + return ( - - {(draggableProvided, draggableSnapshot) => ( - <> - - - {children} - - - - - )} - + + + {children} + + + + {isDropTarget && !isDragSource && } + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowDiv.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowDiv.tsx index 83e17dd44e..909ccac0ed 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowDiv.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowDiv.tsx @@ -10,6 +10,8 @@ const StyledTr = styled.div<{ display: flex; flex-direction: row; + position: relative; + &[data-focused='true'], &[data-active='true'] { div.table-cell, diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyVirtualizedDraggableClone.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowDragOverlayContent.tsx similarity index 71% rename from packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyVirtualizedDraggableClone.tsx rename to packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowDragOverlayContent.tsx index b43e62056e..f71d2f9517 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyVirtualizedDraggableClone.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowDragOverlayContent.tsx @@ -1,3 +1,9 @@ +import { type Draggable } from '@dnd-kit/dom'; +import { styled } from '@linaria/react'; +import { useMemo } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { MOBILE_VIEWPORT, themeCssVariables } from 'twenty-ui/theme-constants'; + import { HorizontalScrollBoxShadowCSS } from '@/object-record/record-table/components/HorizontalScrollBoxShadowCSS'; import { getRecordTableColumnWidthInlineStyles } from '@/object-record/record-table/components/RecordTableStyleWrapper'; import { RECORD_TABLE_COLUMN_ADD_COLUMN_BUTTON_WIDTH } from '@/object-record/record-table/constants/RecordTableColumnAddColumnButtonWidth'; @@ -19,22 +25,11 @@ import { RecordTableCellDragAndDrop } from '@/object-record/record-table/record- import { RecordTableLastEmptyCell } from '@/object-record/record-table/record-table-cell/components/RecordTableLastEmptyCell'; import { RecordTablePlusButtonCellPlaceholder } from '@/object-record/record-table/record-table-cell/components/RecordTablePlusButtonCellPlaceholder'; import { RecordTableFieldsCells } from '@/object-record/record-table/record-table-row/components/RecordTableFieldsCells'; - -import { RecordTableRowMultiDragPreview } from '@/object-record/record-table/record-table-row/components/RecordTableRowMultiDragPreview'; +import { RecordTableRowMultiDragCounterChip } from '@/object-record/record-table/record-table-row/components/RecordTableRowMultiDragCounterChip'; import { RecordTableTr } from '@/object-record/record-table/record-table-row/components/RecordTableTr'; -import { useIsTableRowSecondaryDragged } from '@/object-record/record-table/record-table-row/hooks/useIsRecordSecondaryDragged'; +import { type RecordTableRowDragData } from '@/object-record/record-table/types/RecordTableRowDragData'; import { getRecordTableColumnFieldWidthClassName } from '@/object-record/record-table/utils/getRecordTableColumnFieldWidthClassName'; -import { recordIdByRealIndexComponentFamilySelector } from '@/object-record/record-table/virtualization/states/recordIdByRealIndexComponentFamilySelector'; -import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue'; -import { - type DraggableProvided, - type DraggableRubric, - type DraggableStateSnapshot, -} from '@hello-pangea/dnd'; -import { styled } from '@linaria/react'; -import { useMemo } from 'react'; -import { isDefined } from 'twenty-shared/utils'; -import { MOBILE_VIEWPORT, themeCssVariables } from 'twenty-ui/theme-constants'; +import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement'; const MAX_COLUMNS = 100; @@ -64,7 +59,11 @@ const cloneColumnFieldWidthRules = Array.from( }, ).join('\n'); -const StyledRowDraggableCloneCSSBridge = styled.div` +// The overlay renders in a portal outside the table, so the column width CSS +// variables and sticky cell rules of the table ancestors are redeclared here. +const StyledRowDragOverlayCSSBridge = styled.div` + position: relative; + div.table-cell.${RECORD_TABLE_COLUMN_DRAG_AND_DROP_WIDTH_CLASS_NAME} { left: 0px; position: sticky; @@ -123,27 +122,28 @@ const StyledRowDraggableCloneCSSBridge = styled.div` } `; -export const RecordTableBodyVirtualizedDraggableClone = ({ - draggableProvided, - draggableSnapshot, - rubric, +// The full-width row preview would overhang overlays such as the record side +// panel when the visible table is narrower than the row, so it is clipped to +// the scroll wrapper's width. The multi-drag counter chip renders outside +// this container because it pokes past the row's top-left corner. +const StyledRowClipContainer = styled.div` + overflow: hidden; +`; + +export const RecordTableRowDragOverlayContent = ({ + source, }: { - draggableProvided: DraggableProvided; - draggableSnapshot: DraggableStateSnapshot; - rubric: DraggableRubric; + source: Draggable | null; }) => { - const realIndex = rubric.source.index; - - const recordId = useAtomComponentFamilySelectorValue( - recordIdByRealIndexComponentFamilySelector, - realIndex, - ); - const { lastColumnWidth } = useRecordTableLastColumnWidthToFill(); - const { visibleRecordFields } = useRecordTableContextOrThrow(); + const { visibleRecordFields, recordTableId } = useRecordTableContextOrThrow(); - const { isSecondaryDragged } = useIsTableRowSecondaryDragged(recordId); + const { scrollWrapperHTMLElement } = useScrollWrapperHTMLElement( + `record-table-scroll-${recordTableId}`, + ); + + const visibleTableWidth = scrollWrapperHTMLElement?.clientWidth; const columnWidthStyles = useMemo(() => { const styles: Record = @@ -156,47 +156,45 @@ export const RecordTableBodyVirtualizedDraggableClone = ({ return styles; }, [visibleRecordFields, lastColumnWidth]); - if (!isDefined(recordId)) { + const sourceData = source?.data as RecordTableRowDragData | undefined; + + if (!isDefined(source) || !isDefined(sourceData)) { return null; } + const recordId = sourceData.recordId; + return ( - - {}} + + - {}} > - - - - - - - - - + + + + + + + + + + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableStaticTr.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableStaticTr.tsx index aa18de2922..a88beda003 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableStaticTr.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableStaticTr.tsx @@ -25,7 +25,6 @@ export const RecordTableStaticTr = ({ {children} diff --git a/packages/twenty-front/src/modules/object-record/record-table/types/RecordTableRowDragData.ts b/packages/twenty-front/src/modules/object-record/record-table/types/RecordTableRowDragData.ts new file mode 100644 index 0000000000..8bd3468a68 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/types/RecordTableRowDragData.ts @@ -0,0 +1,6 @@ +import { type DragDropItemData } from '@/ui/utilities/drag-and-drop/types/DragDropItemData'; + +export type RecordTableRowDragData = DragDropItemData & { + recordId: string; + focusIndex: number; +}; diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutGridLayout.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutGridLayout.tsx index e4e967b016..55148a827c 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutGridLayout.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutGridLayout.tsx @@ -12,6 +12,7 @@ import { PAGE_LAYOUT_GRID_ITEM_Z_INDEX } from '@/page-layout/constants/PageLayou import { PAGE_LAYOUT_GRID_MARGIN } from '@/page-layout/constants/PageLayoutGridMargin'; import { PAGE_LAYOUT_GRID_ROW_HEIGHT } from '@/page-layout/constants/PageLayoutGridRowHeight'; import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode'; +import { usePageLayoutGridCrossTabDrop } from '@/page-layout/hooks/usePageLayoutGridCrossTabDrop'; import { usePageLayoutHandleLayoutChange } from '@/page-layout/hooks/usePageLayoutHandleLayoutChange'; import { usePageLayoutTabWithVisibleWidgetsOrThrow } from '@/page-layout/hooks/usePageLayoutTabWithVisibleWidgetsOrThrow'; import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; @@ -155,6 +156,14 @@ export const PageLayoutGridLayout = ({ tabId }: PageLayoutGridLayoutProps) => { tabListInstanceId, }); + const { + handleGridDrag, + handleGridDragStop, + consumeShouldIgnoreNextGridLayoutChange, + } = usePageLayoutGridCrossTabDrop({ + tabId, + }); + const gridContainerRef = useRef(null); const isPageLayoutInEditMode = useIsPageLayoutInEditMode(); @@ -183,6 +192,10 @@ export const PageLayoutGridLayout = ({ tabId }: PageLayoutGridLayoutProps) => { currentLayout: Layout[], allLayouts: Layouts, ) => { + if (consumeShouldIgnoreNextGridLayoutChange()) { + return; + } + handleLayoutChange( currentLayout, filterPendingPlaceholderFromLayouts(allLayouts), @@ -247,7 +260,11 @@ export const PageLayoutGridLayout = ({ tabId }: PageLayoutGridLayoutProps) => { onDragStart={(_layout, _oldItem, newItem) => { setPageLayoutDraggingWidgetId(newItem.i); }} - onDragStop={() => { + onDrag={(_layout, _oldItem, _newItem, _placeholder, event) => { + handleGridDrag(event); + }} + onDragStop={(_layout, _oldItem, newItem, _placeholder, event) => { + handleGridDragStop(newItem.i, event); setPageLayoutDraggingWidgetId(null); }} onResizeStart={(_layout, _oldItem, newItem) => { diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabList.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabList.tsx index 61865bc7ef..b8420b6e55 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabList.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabList.tsx @@ -1,11 +1,4 @@ -import { - DragDropContext, - type DropResult, - type OnDragEndResponder, - type OnDragStartResponder, - type OnDragUpdateResponder, - type ResponderProvided, -} from '@hello-pangea/dnd'; +import { useDragDropMonitor } from '@dnd-kit/react'; import { styled } from '@linaria/react'; import { useLingui } from '@lingui/react/macro'; import { useCallback, useMemo } from 'react'; @@ -29,15 +22,16 @@ import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomC import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState'; import { PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS } from '@/page-layout/components/PageLayoutTabListDroppableIds'; +import { PAGE_LAYOUT_TAB_LIST_END_DROP_ZONE_WIDTH } from '@/page-layout/constants/PageLayoutTabListEndDropZoneWidth'; import { PageLayoutTabListNewTabDropdownContent } from '@/page-layout/components/PageLayoutTabListNewTabDropdownContent'; import { PageLayoutTabListReorderableOverflowDropdown } from '@/page-layout/components/PageLayoutTabListReorderableOverflowDropdown'; import { PageLayoutTabListVisibleTabs } from '@/page-layout/components/PageLayoutTabListVisibleTabs'; import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode'; import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; -import { pageLayoutTabListCurrentDragDroppableIdComponentState } from '@/page-layout/states/pageLayoutTabListCurrentDragDroppableIdComponentState'; import { pageLayoutTabSettingsOpenTabIdComponentState } from '@/page-layout/states/pageLayoutTabSettingsOpenTabIdComponentState'; import { type PageLayoutAddTabStrategy } from '@/page-layout/types/PageLayoutAddTabStrategy'; import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab'; +import { type PageLayoutWidgetDndData } from '@/page-layout/types/PageLayoutWidgetDndData'; import { shouldEnableTabEditingFeatures } from '@/page-layout/utils/shouldEnableTabEditingFeatures'; import { useNavigatePageLayoutSidePanel } from '@/side-panel/pages/page-layout/hooks/useNavigatePageLayoutSidePanel'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; @@ -90,7 +84,6 @@ type PageLayoutTabListProps = Omit & { tabs: PageLayoutTab[]; isReorderEnabled: boolean; addTabStrategy?: PageLayoutAddTabStrategy; - onReorder?: (result: DropResult, provided: ResponderProvided) => boolean; behaveAsLinks: boolean; pageLayoutType: PageLayoutType; }; @@ -105,7 +98,6 @@ export const PageLayoutTabList = ({ onChangeTab, addTabStrategy, isReorderEnabled, - onReorder, pageLayoutType, }: PageLayoutTabListProps) => { const { getIcon } = useIcons(); @@ -186,25 +178,43 @@ export const PageLayoutTabList = ({ closeDropdown(dropdownId); }, [closeDropdown, dropdownId]); - const setPageLayoutTabListCurrentDragDroppableId = useSetAtomComponentState( - pageLayoutTabListCurrentDragDroppableIdComponentState, - pageLayoutId, - ); + // The overflow dropdown must survive drops into itself: the dragging flag + // suppresses its close-on-click-outside while a tab drag is in flight, and a + // drop on the more button reopens it on the freshly appended tab. + useDragDropMonitor({ + onDragStart: (event) => { + const sourceData = event.operation.source?.data as + | PageLayoutWidgetDndData + | undefined; - const handleDragUpdate: OnDragUpdateResponder = (update) => { - setPageLayoutTabListCurrentDragDroppableId(update.destination?.droppableId); - }; + if (sourceData?.type !== 'tab') { + return; + } - const handleDragStart = useCallback(() => { - setIsPageLayoutTabDragging(true); - toggleClickOutside(false); - }, [setIsPageLayoutTabDragging, toggleClickOutside]); + setIsPageLayoutTabDragging(true); + toggleClickOutside(false); + }, + onDragEnd: (event) => { + const sourceData = event.operation.source?.data as + | PageLayoutWidgetDndData + | undefined; + + if (sourceData?.type !== 'tab') { + return; + } + + const target = event.operation.target; + const targetData = target?.data as PageLayoutWidgetDndData | undefined; + const targetDroppableId = ( + target?.data as { droppableId?: string } | undefined + )?.droppableId; - const handleDragEnd = useCallback( - (result, provided) => { const droppedInOverflow = - result.destination?.droppableId === - PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.OVERFLOW_TABS; + !event.canceled && + (targetDroppableId === + PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.OVERFLOW_TABS || + String(target?.id) === + `${PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.OVERFLOW_TABS}-end`); if (!droppedInOverflow) { setIsPageLayoutTabDragging(false); @@ -212,26 +222,13 @@ export const PageLayoutTabList = ({ toggleClickOutside(true); - if (!onReorder) { - return; - } - - const shouldOpenDropdown = onReorder(result, provided); - - if (shouldOpenDropdown === true) { + if (!event.canceled && targetData?.type === 'tab-more-button') { openDropdown({ dropdownComponentInstanceIdFromProps: dropdownId, }); } }, - [ - onReorder, - setIsPageLayoutTabDragging, - toggleClickOutside, - openDropdown, - dropdownId, - ], - ); + }); const isPageLayoutInEditMode = useIsPageLayoutInEditMode(); const pageLayoutTabSettingsOpenTabId = useAtomComponentStateValue( @@ -257,6 +254,25 @@ export const PageLayoutTabList = ({ const isTabSettingsOpen = isDefined(pageLayoutTabSettingsOpenTabId); + // The reorderable strip appends an end drop zone the tab measurement does + // not know about; reserve its width so visible tabs never get clipped. + const handleContainerWidthChange = useCallback( + (dimensions: { width: number; height: number }) => { + onContainerWidthChange( + isReorderEnabled + ? { + ...dimensions, + width: Math.max( + dimensions.width - PAGE_LAYOUT_TAB_LIST_END_DROP_ZONE_WIDTH, + 0, + ), + } + : dimensions, + ); + }, + [onContainerWidthChange, isReorderEnabled], + ); + const handleSelectTab = useCallback( (tabId: string) => { const shouldOpenSettings = @@ -317,14 +333,15 @@ export const PageLayoutTabList = ({ return null; } - const canReorderTabs = isReorderEnabled && isDefined(onReorder); + const canReorderTabs = isReorderEnabled; const shouldRenderReorderableDropdown = hasHiddenTabs && canReorderTabs; const shouldRenderStaticDropdown = hasHiddenTabs && !canReorderTabs; - // Widgets can only be dropped onto record-page vertical-list tabs; other tab - // types keep their native (canvas/grid) widget placement. + // Record pages accept widget drops on vertical-list tabs (dnd-kit drags); + // dashboards accept them on grid tabs (react-grid-layout drags bridged by + // pointer hit-testing). const widgetDropTargetTabIds = new Set( pageLayoutType === PageLayoutType.RECORD_PAGE ? tabs @@ -332,7 +349,13 @@ export const PageLayoutTabList = ({ (tab) => tab.layoutMode === PageLayoutTabLayoutMode.VERTICAL_LIST, ) .map((tab) => tab.id) - : [], + : pageLayoutType === PageLayoutType.DASHBOARD + ? tabs + .filter( + (tab) => tab.layoutMode !== PageLayoutTabLayoutMode.VERTICAL_LIST, + ) + .map((tab) => tab.id) + : [], ); return ( @@ -369,142 +392,91 @@ export const PageLayoutTabList = ({ /> )} - - {isReorderEnabled && onReorder ? ( - - - + + + + {shouldRenderReorderableDropdown && ( + + + + )} - {shouldRenderReorderableDropdown && ( - - - - )} + {shouldRenderStaticDropdown && ( + + + + )} - {addTabStrategy?.mode === 'direct' && ( - + {addTabStrategy?.mode === 'direct' && ( + + addTabStrategy.onCreate()} + disableTestId + /> + + )} + {addTabStrategy?.mode === 'dropdown' && ( + + addTabStrategy.onCreate()} disableTestId /> - - )} - {addTabStrategy?.mode === 'dropdown' && ( - - - } - dropdownComponents={ - - } - dropdownPlacement="bottom-start" /> - - )} - - - ) : ( - - - {shouldRenderStaticDropdown && ( - - - - )} - {addTabStrategy?.mode === 'direct' && ( - - addTabStrategy.onCreate()} - disableTestId - /> - - )} - {addTabStrategy?.mode === 'dropdown' && ( - - - } - dropdownComponents={ - - } - dropdownPlacement="bottom-start" - /> - - )} - - )} + } + dropdownPlacement="bottom-start" + /> + + )} + ); diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListDroppableMoreButton.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListDroppableMoreButton.tsx index b700d4500f..13358dc7e0 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListDroppableMoreButton.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListDroppableMoreButton.tsx @@ -1,7 +1,10 @@ +import { pointerIntersection } from '@dnd-kit/collision'; +import { useDroppable } from '@dnd-kit/react'; import { styled } from '@linaria/react'; -import { Droppable } from '@hello-pangea/dnd'; import { PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS } from '@/page-layout/components/PageLayoutTabListDroppableIds'; +import { PAGE_LAYOUT_TAB_DND_TYPE } from '@/page-layout/constants/PageLayoutTabDndType'; +import { type PageLayoutTabMoreButtonDropData } from '@/page-layout/types/PageLayoutTabMoreButtonDropData'; import { TabMoreButton } from '@/ui/layout/tab-list/components/TabMoreButton'; import { themeCssVariables } from 'twenty-ui/theme-constants'; @@ -24,22 +27,25 @@ export const PageLayoutTabListDroppableMoreButton = ({ hiddenTabsCount, isActiveTabHidden, }: PageLayoutTabListDroppableMoreButtonProps) => { + const moreButtonDropData: PageLayoutTabMoreButtonDropData = { + type: 'tab-more-button', + }; + + const { ref, isDropTarget } = useDroppable({ + id: PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.MORE_BUTTON, + accept: PAGE_LAYOUT_TAB_DND_TYPE, + collisionDetector: pointerIntersection, + data: moreButtonDropData, + }); + return ( - - {(provided, snapshot) => ( -
- - - -
- )} -
+
+ + + +
); }; diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableOverflowDropdown.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableOverflowDropdown.tsx index 05ddbd3eb9..f1860162aa 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableOverflowDropdown.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableOverflowDropdown.tsx @@ -1,20 +1,15 @@ -import { - Draggable, - type DraggableProvided, - type DraggableRubric, - type DraggableStateSnapshot, - Droppable, -} from '@hello-pangea/dnd'; import { styled } from '@linaria/react'; import { PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS } from '@/page-layout/components/PageLayoutTabListDroppableIds'; import { PageLayoutTabListDroppableMoreButton } from '@/page-layout/components/PageLayoutTabListDroppableMoreButton'; import { PageLayoutTabMenuItemSelectAvatar } from '@/page-layout/components/PageLayoutTabMenuItemSelectAvatar'; -import { PageLayoutTabRenderClone } from '@/page-layout/components/PageLayoutTabRenderClone'; +import { PAGE_LAYOUT_TAB_DND_TYPE } from '@/page-layout/constants/PageLayoutTabDndType'; import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode'; import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; import { isPageLayoutTabDraggingComponentState } from '@/page-layout/states/isPageLayoutTabDraggingComponentState'; import { pageLayoutTabSettingsOpenTabIdComponentState } from '@/page-layout/states/pageLayoutTabSettingsOpenTabIdComponentState'; +import { type PageLayoutTabDragData } from '@/page-layout/types/PageLayoutTabDragData'; +import { type PageLayoutTabListEndDropData } from '@/page-layout/types/PageLayoutTabListEndDropData'; import { shouldEnableTabEditingFeatures } from '@/page-layout/utils/shouldEnableTabEditingFeatures'; import { useNavigatePageLayoutSidePanel } from '@/side-panel/pages/page-layout/hooks/useNavigatePageLayoutSidePanel'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; @@ -23,23 +18,37 @@ import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/Drop import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; import { TabListComponentInstanceContext } from '@/ui/layout/tab-list/states/contexts/TabListComponentInstanceContext'; import { type SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps'; +import { DragDropItemEndDropZone } from '@/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone'; +import { DragDropItemSortableCell } from '@/ui/utilities/drag-and-drop/components/DragDropItemSortableCell'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState'; import { useContext } from 'react'; import { SidePanelPages } from 'twenty-shared/types'; -import { ThemeContext } from 'twenty-ui/theme-constants'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; import { type PageLayoutType } from '~/generated-metadata/graphql'; -const StyledOverflowDropdownListDraggableWrapper = styled.div` +const StyledOverflowMenuItemWrapper = styled.div` cursor: grab; display: flex; + min-width: 100%; &:active { cursor: grabbing; } `; +// Kept tall enough that appending after the last overflow tab stays an easy +// target. +const StyledOverflowEndDropZone = styled(DragDropItemEndDropZone)` + min-height: ${themeCssVariables.spacing[4]}; +`; + +const OVERFLOW_END_DROP_DATA: PageLayoutTabListEndDropData = { + type: 'tab-list-end', + beforeTabId: null, +}; + type PageLayoutTabListReorderableOverflowDropdownProps = { dropdownId: string; hiddenTabs: SingleTabProps[]; @@ -65,7 +74,6 @@ export const PageLayoutTabListReorderableOverflowDropdown = ({ onClose, pageLayoutType, }: PageLayoutTabListReorderableOverflowDropdownProps) => { - const { theme } = useContext(ThemeContext); const context = useContext(TabListComponentInstanceContext); const instanceId = context?.instanceId; @@ -130,95 +138,46 @@ export const PageLayoutTabListReorderableOverflowDropdown = ({ } dropdownComponents={ - { - const overflowIndex = rubric.source.index - visibleTabCount; - const tab = hiddenTabs[overflowIndex]; + + {hiddenTabs.map((tab, index) => { + const disabled = tab.disabled ?? loading; + const tabDragData: PageLayoutTabDragData = { + type: 'tab', + tabId: tab.id, + }; return ( - - ); - }} - > - {(provided) => ( - -
- {hiddenTabs.map((tab, index) => { - const globalIndex = visibleTabCount + index; - const disabled = tab.disabled ?? loading; - - return ( - - {(draggableProvided, draggableSnapshot) => ( - -
- handleTabSelect(tab.id) - } - disabled={disabled} - showEditButton={shouldShowEditButton} - onEditClick={handleEditClick} - /> -
-
- )} -
- ); - })} -
{provided.placeholder}
-
-
- )} -
+ + handleTabSelect(tab.id)} + disabled={disabled} + showEditButton={shouldShowEditButton} + onEditClick={handleEditClick} + /> + + + ); + })} + +
} /> diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableTab.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableTab.tsx index 3827f5d95a..1bd7de6f7c 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableTab.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableTab.tsx @@ -1,8 +1,9 @@ -import { Draggable } from '@hello-pangea/dnd'; - import { PageLayoutTabWidgetDropTarget } from '@/page-layout/components/dnd/PageLayoutTabWidgetDropTarget'; +import { PAGE_LAYOUT_TAB_DND_TYPE } from '@/page-layout/constants/PageLayoutTabDndType'; import { pageLayoutTabSettingsOpenTabIdComponentState } from '@/page-layout/states/pageLayoutTabSettingsOpenTabIdComponentState'; +import { type PageLayoutTabDragData } from '@/page-layout/types/PageLayoutTabDragData'; import { type SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps'; +import { DragDropItemSortableCell } from '@/ui/utilities/drag-and-drop/components/DragDropItemSortableCell'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { styled } from '@linaria/react'; import { StyledTabContainer, TabContent } from 'twenty-ui/input'; @@ -11,9 +12,11 @@ import { themeCssVariables } from 'twenty-ui/theme-constants'; type PageLayoutTabListReorderableTabProps = { tab: SingleTabProps; index: number; + group: string; isActive: boolean; disabled?: boolean; isWidgetDropTarget?: boolean; + dropLineOrientation?: 'horizontal' | 'vertical'; onSelect: () => void; }; @@ -27,9 +30,11 @@ const StyledTabContentWrapper = styled.div<{ isBeingEdited: boolean }>` export const PageLayoutTabListReorderableTab = ({ tab, index, + group, isActive, disabled, isWidgetDropTarget = false, + dropLineOrientation = 'vertical', onSelect, }: PageLayoutTabListReorderableTabProps) => { const pageLayoutTabSettingsOpenTabId = useAtomComponentStateValue( @@ -38,37 +43,42 @@ export const PageLayoutTabListReorderableTab = ({ const isSettingsOpenForThisTab = pageLayoutTabSettingsOpenTabId === tab.id; + const tabDragData: PageLayoutTabDragData = { + type: 'tab', + tabId: tab.id, + }; + const draggableTab = ( - - {(draggableProvided, draggableSnapshot) => ( - - - - - - )} - + + + + + + + ); if (!isWidgetDropTarget) { diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListVisibleTabs.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListVisibleTabs.tsx index 96f0f2f157..8d3f02f820 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListVisibleTabs.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListVisibleTabs.tsx @@ -1,10 +1,4 @@ import { styled } from '@linaria/react'; -import { - type DraggableProvided, - type DraggableRubric, - type DraggableStateSnapshot, - Droppable, -} from '@hello-pangea/dnd'; import { TabButton } from 'twenty-ui/input'; import { TAB_LIST_GAP } from '@/ui/layout/tab-list/constants/TabListGap'; @@ -12,7 +6,10 @@ import { type SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps'; import { PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS } from '@/page-layout/components/PageLayoutTabListDroppableIds'; import { PageLayoutTabListReorderableTab } from '@/page-layout/components/PageLayoutTabListReorderableTab'; -import { PageLayoutTabRenderClone } from '@/page-layout/components/PageLayoutTabRenderClone'; +import { PAGE_LAYOUT_TAB_DND_TYPE } from '@/page-layout/constants/PageLayoutTabDndType'; +import { PAGE_LAYOUT_TAB_LIST_END_DROP_ZONE_WIDTH } from '@/page-layout/constants/PageLayoutTabListEndDropZoneWidth'; +import { type PageLayoutTabListEndDropData } from '@/page-layout/types/PageLayoutTabListEndDropData'; +import { DragDropItemEndDropZone } from '@/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone'; type PageLayoutTabListVisibleTabsProps = { visibleTabs: SingleTabProps[]; @@ -24,6 +21,7 @@ type PageLayoutTabListVisibleTabsProps = { onSelectTab: (tabId: string) => void; canReorder: boolean; widgetDropTargetTabIds: Set; + firstHiddenTabId: string | null; }; const StyledTabContainer = styled.div` @@ -37,6 +35,14 @@ const StyledTabContainer = styled.div` } `; +// Catches drops after the last visible tab; inserting before the first hidden +// tab keeps the dropped tab visible instead of sending it to the overflow. +// Its width is reserved by PageLayoutTabList's container measurement. +const StyledEndDropZone = styled(DragDropItemEndDropZone)` + align-self: stretch; + flex: 0 0 ${PAGE_LAYOUT_TAB_LIST_END_DROP_ZONE_WIDTH}px; +`; + export const PageLayoutTabListVisibleTabs = ({ visibleTabs, visibleTabCount, @@ -47,49 +53,35 @@ export const PageLayoutTabListVisibleTabs = ({ onSelectTab, canReorder, widgetDropTargetTabIds, + firstHiddenTabId, }: PageLayoutTabListVisibleTabsProps) => { if (canReorder) { - return ( - { - const tab = visibleTabs[rubric.source.index]; + const endDropData: PageLayoutTabListEndDropData = { + type: 'tab-list-end', + beforeTabId: firstHiddenTabId, + }; - return ( - - ); - }} - > - {(provided) => ( - - {visibleTabs.slice(0, visibleTabCount).map((tab, index) => ( - onSelectTab(tab.id)} - /> - ))} - {provided.placeholder} - - )} - + return ( + + {visibleTabs.slice(0, visibleTabCount).map((tab, index) => ( + onSelectTab(tab.id)} + /> + ))} + + ); } diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabRenderClone.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabRenderClone.tsx deleted file mode 100644 index 59e8080b28..0000000000 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabRenderClone.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { isDefined } from 'twenty-shared/utils'; -import { PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS } from '@/page-layout/components/PageLayoutTabListDroppableIds'; -import { pageLayoutTabListCurrentDragDroppableIdComponentState } from '@/page-layout/states/pageLayoutTabListCurrentDragDroppableIdComponentState'; -import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; -import { TabAvatar } from '@/ui/layout/tab-list/components/TabAvatar'; -import { type SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps'; -import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; -import { type DraggableProvided } from '@hello-pangea/dnd'; -import { styled } from '@linaria/react'; -import { useContext } from 'react'; -import { StyledTabContainer, TabContent } from 'twenty-ui/input'; -import { MenuItemSelectAvatar } from 'twenty-ui/navigation'; -import { ThemeContext } from 'twenty-ui/theme-constants'; -const StyledDraggableWrapper = styled.div` - cursor: grab; - display: flex; - - &:active { - cursor: grabbing; - } -`; - -export const PageLayoutTabRenderClone = ({ - tab, - provided, - activeTabId, -}: { - tab: SingleTabProps; - provided: DraggableProvided; - activeTabId: string | null; -}) => { - const { theme } = useContext(ThemeContext); - const pageLayoutTabListCurrentDragDroppableId = useAtomComponentStateValue( - pageLayoutTabListCurrentDragDroppableIdComponentState, - ); - - const isHoveringTabList = - pageLayoutTabListCurrentDragDroppableId !== - PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.OVERFLOW_TABS; - - if (!isDefined(tab)) return null; - - if (isHoveringTabList) { - return ( - - - - - - ); - } else { - return ( - -
- } - selected={tab.id === activeTabId} - onClick={undefined} - disabled - /> -
-
- ); - } -}; diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabsRenderer.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabsRenderer.tsx index 7c818f866a..62fc78bba0 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabsRenderer.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabsRenderer.tsx @@ -11,7 +11,6 @@ import { WIDGET_TYPE_TO_RELATION_FIELD_NAME } from '@/page-layout/constants/Widg import { useCurrentPageLayoutOrThrow } from '@/page-layout/hooks/useCurrentPageLayoutOrThrow'; import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode'; import { usePageLayoutAddTabStrategy } from '@/page-layout/hooks/usePageLayoutAddTabStrategy'; -import { useReorderRecordPageLayoutTabs } from '@/page-layout/hooks/useReorderRecordPageLayoutTabs'; import { PageLayoutMainContent } from '@/page-layout/PageLayoutMainContent'; import { getScrollWrapperInstanceIdFromPageLayoutId } from '@/page-layout/utils/getScrollWrapperInstanceIdFromPageLayoutId'; import { getTabListInstanceIdFromPageLayoutAndRecord } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutAndRecord'; @@ -95,10 +94,6 @@ export const PageLayoutTabsRenderer = () => { tabListInstanceId, }); - const { reorderRecordPageTabs } = useReorderRecordPageLayoutTabs( - currentPageLayout.id, - ); - const { objectMetadataItems } = useObjectMetadataItems(); const inactiveRelationFieldNames = useMemo(() => { @@ -217,16 +212,6 @@ export const PageLayoutTabsRenderer = () => { componentInstanceId={tabListInstanceId} addTabStrategy={addTabStrategy} isReorderEnabled={canEnableTabEditing} - onReorder={ - canEnableTabEditing - ? (result, provided) => - reorderRecordPageTabs( - result, - provided, - isDefined(pinnedLeftTab), - ) - : undefined - } pageLayoutType={currentPageLayout.type} /> )} diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutVerticalListEditor.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutVerticalListEditor.tsx index 012b3c6e8c..f1e0c8471c 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutVerticalListEditor.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutVerticalListEditor.tsx @@ -1,15 +1,15 @@ -import { pointerIntersection } from '@dnd-kit/collision'; -import { useDroppable } from '@dnd-kit/react'; -import { PageLayoutWidgetDropLine } from '@/page-layout/components/dnd/PageLayoutWidgetDropLine'; -import { PageLayoutWidgetSortableItem } from '@/page-layout/components/dnd/PageLayoutWidgetSortableItem'; import { getPageLayoutVerticalListViewerVariant } from '@/page-layout/components/utils/getPageLayoutVerticalListViewerVariant'; import { usePageLayoutContentContext } from '@/page-layout/contexts/PageLayoutContentContext'; import { type PageLayoutVerticalListViewerVariant } from '@/page-layout/types/PageLayoutVerticalListViewerVariant'; import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; -import { type PageLayoutWidgetListDropData } from '@/page-layout/types/PageLayoutWidgetDndData'; +import { type PageLayoutWidgetDragData } from '@/page-layout/types/PageLayoutWidgetDragData'; +import { type PageLayoutWidgetListDropData } from '@/page-layout/types/PageLayoutWidgetListDropData'; +import { PAGE_LAYOUT_WIDGET_DND_TYPE } from '@/page-layout/constants/PageLayoutWidgetDndType'; import { WidgetRenderer } from '@/page-layout/widgets/components/WidgetRenderer'; import { useIsInPinnedTab } from '@/page-layout/widgets/hooks/useIsInPinnedTab'; import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext'; +import { DragDropItemEndDropZone } from '@/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone'; +import { DragDropItemSortableCell } from '@/ui/utilities/drag-and-drop/components/DragDropItemSortableCell'; import { styled } from '@linaria/react'; import { type ReactNode } from 'react'; import { themeCssVariables } from 'twenty-ui/theme-constants'; @@ -32,15 +32,12 @@ const StyledVerticalListContainer = styled.div<{ : themeCssVariables.spacing[2]}; `; -// Catches drops below the last widget (append) and drops into an empty tab, -// where there is no sortable item to target. -const StyledEndDropZone = styled.div` +const StyledEndDropZone = styled(DragDropItemEndDropZone)` display: flex; flex: 1; flex-direction: column; gap: ${themeCssVariables.spacing[4]}; min-height: ${themeCssVariables.spacing[6]}; - position: relative; `; type PageLayoutVerticalListEditorProps = { @@ -69,29 +66,41 @@ export const PageLayoutVerticalListEditor = ({ tabId, }; - const { ref: endDropRef, isDropTarget: isEndDropTarget } = useDroppable({ - id: `page-layout-widget-list-${tabId}`, - collisionDetector: pointerIntersection, - data: endDropData, - }); - return ( - {widgets.map((widget, index) => ( - - - - ))} - - {isEndDropTarget && } + {widgets.map((widget, index) => { + const widgetDragData: PageLayoutWidgetDragData = { + type: 'widget', + widgetId: widget.id, + tabId, + index, + }; + + return ( + + + + ); + })} + {trailingElement} diff --git a/packages/twenty-front/src/modules/page-layout/components/__stories__/PageLayoutTabList.stories.tsx b/packages/twenty-front/src/modules/page-layout/components/__stories__/PageLayoutTabList.stories.tsx index 245bf2d65f..988221c214 100644 --- a/packages/twenty-front/src/modules/page-layout/components/__stories__/PageLayoutTabList.stories.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/__stories__/PageLayoutTabList.stories.tsx @@ -1,16 +1,16 @@ -import { type DropResult, type ResponderProvided } from '@hello-pangea/dnd'; import { styled } from '@linaria/react'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo } from 'react'; import { ComponentWithRouterDecorator } from 'twenty-ui/testing'; import { PageLayoutTabList } from '@/page-layout/components/PageLayoutTabList'; -import { PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS } from '@/page-layout/components/PageLayoutTabListDroppableIds'; import { PageLayoutTabListEffect } from '@/page-layout/components/PageLayoutTabListEffect'; +import { PageLayoutWidgetDndProvider } from '@/page-layout/components/dnd/PageLayoutWidgetDndProvider'; import { PageLayoutEditModeProviderContext } from '@/page-layout/contexts/PageLayoutEditModeContext'; import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; +import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab'; -import { calculateNewPosition } from '@/ui/layout/draggable-list/utils/calculateNewPosition'; +import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState'; import { themeCssVariables } from 'twenty-ui/theme-constants'; import { PageLayoutType } from '~/generated-metadata/graphql'; @@ -68,94 +68,46 @@ const PageLayoutTabListPlayground = ({ }: { isReorderEnabled: boolean; }) => { - const [tabs, setTabs] = useState(createInitialTabs()); - const [nextIndex, setNextIndex] = useState(tabs.length); + // Tab drops are routed into the page-layout draft by the dnd provider, so + // the story renders from that draft to stay interactive. + const [pageLayoutDraft, setPageLayoutDraft] = useAtomComponentState( + pageLayoutDraftComponentState, + ); + + useEffect(() => { + setPageLayoutDraft((prev) => + prev.tabs.length > 0 ? prev : { ...prev, tabs: createInitialTabs() }, + ); + }, [setPageLayoutDraft]); const sortedTabs = useMemo(() => { - return [...tabs].sort((a, b) => a.position - b.position); - }, [tabs]); + return [...pageLayoutDraft.tabs].sort((a, b) => a.position - b.position); + }, [pageLayoutDraft.tabs]); const handleAddTab = () => { - setTabs((prev) => [ - ...prev, - { - __typename: 'PageLayoutTab', - isActive: true, - applicationId: '', - id: `new-tab-${nextIndex}`, - title: `New Tab ${nextIndex}`, - position: nextIndex, - pageLayoutId: 'test-layout', - widgets: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - deletedAt: null, - }, - ]); - setNextIndex((value) => value + 1); - }; + setPageLayoutDraft((prev) => { + const nextIndex = prev.tabs.length; - const handleReorder = ( - result: DropResult, - _provided: ResponderProvided, - ): boolean => { - const { destination, source, draggableId } = result; - - if (!destination) { - return false; - } - - const isDroppedOnMoreButton = - destination.droppableId === - PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.MORE_BUTTON; - - if (isDroppedOnMoreButton) { - setTabs((prev) => { - const maxPosition = Math.max(...prev.map((tab) => tab.position), 0); - return prev.map((tab) => - tab.id === draggableId ? { ...tab, position: maxPosition + 1 } : tab, - ); - }); - return true; - } - - setTabs((prev) => { - const sorted = [...prev].sort((a, b) => a.position - b.position); - - if ( - destination.droppableId === source.droppableId && - destination.index === source.index - ) { - return prev; - } - - const draggedTab = sorted.find((tab) => tab.id === draggableId); - if (!draggedTab) { - return prev; - } - - const withoutDragged = sorted.filter((tab) => tab.id !== draggableId); - - const movingBetweenDroppables = - destination.droppableId !== source.droppableId; - - const destinationIndexAdjusted = - movingBetweenDroppables && destination.index > source.index - ? destination.index - 1 - : destination.index; - - const newPosition = calculateNewPosition({ - destinationIndex: destinationIndexAdjusted, - sourceIndex: source.index, - items: withoutDragged, - }); - - return prev.map((tab) => - tab.id === draggableId ? { ...tab, position: newPosition } : tab, - ); + return { + ...prev, + tabs: [ + ...prev.tabs, + { + __typename: 'PageLayoutTab', + isActive: true, + applicationId: '', + id: `new-tab-${nextIndex}`, + title: `New Tab ${nextIndex}`, + position: nextIndex, + pageLayoutId: 'test-layout', + widgets: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, + }, + ], + }; }); - - return false; }; return ( @@ -165,20 +117,21 @@ const PageLayoutTabListPlayground = ({ componentInstanceId="page-layout-tab-list-story" /> - + + +
); }; diff --git a/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutTabWidgetDropTarget.tsx b/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutTabWidgetDropTarget.tsx index 6e91c54c2e..f2538e40b3 100644 --- a/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutTabWidgetDropTarget.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutTabWidgetDropTarget.tsx @@ -4,7 +4,11 @@ import { styled } from '@linaria/react'; import { type ReactNode } from 'react'; import { themeCssVariables } from 'twenty-ui/theme-constants'; -import { type PageLayoutTabWidgetDropData } from '@/page-layout/types/PageLayoutWidgetDndData'; +import { PAGE_LAYOUT_TAB_DROP_TARGET_DATA_ATTRIBUTE } from '@/page-layout/constants/PageLayoutTabDropTargetDataAttribute'; +import { PAGE_LAYOUT_WIDGET_DND_TYPE } from '@/page-layout/constants/PageLayoutWidgetDndType'; +import { pageLayoutGridDragHoveredTabIdComponentState } from '@/page-layout/states/pageLayoutGridDragHoveredTabIdComponentState'; +import { type PageLayoutTabWidgetDropData } from '@/page-layout/types/PageLayoutTabWidgetDropData'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; const StyledDropTarget = styled.div<{ isActive: boolean }>` border-radius: ${themeCssVariables.border.radius.sm}; @@ -30,12 +34,23 @@ export const PageLayoutTabWidgetDropTarget = ({ const { ref, isDropTarget } = useDroppable({ id: `page-layout-tab-widget-drop-${tabId}`, + accept: PAGE_LAYOUT_WIDGET_DND_TYPE, collisionDetector: pointerIntersection, data, }); + // Grid drags come from react-grid-layout, outside dnd-kit; their hover + // highlight is driven by pointer hit-testing instead of isDropTarget. + const pageLayoutGridDragHoveredTabId = useAtomComponentStateValue( + pageLayoutGridDragHoveredTabIdComponentState, + ); + return ( - + {children} ); diff --git a/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutWidgetDndProvider.tsx b/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutWidgetDndProvider.tsx index 5b5d8ea603..bcc146a7c9 100644 --- a/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutWidgetDndProvider.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutWidgetDndProvider.tsx @@ -3,6 +3,7 @@ import { type ReactNode } from 'react'; import { usePageLayoutWidgetDragAndDrop } from '@/page-layout/hooks/usePageLayoutWidgetDragAndDrop'; import { type PageLayoutWidgetDndData } from '@/page-layout/types/PageLayoutWidgetDndData'; +import { DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION } from '@/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation'; import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors'; type PageLayoutWidgetDndProviderProps = { @@ -21,6 +22,7 @@ export const PageLayoutWidgetDndProvider = ({ return ( sensors={DND_KIT_SENSORS} + plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} onDragStart={handlers.onDragStart} onDragEnd={handlers.onDragEnd} > diff --git a/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutWidgetDropLine.tsx b/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutWidgetDropLine.tsx deleted file mode 100644 index 0d8da68a5c..0000000000 --- a/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutWidgetDropLine.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { styled } from '@linaria/react'; -import { themeCssVariables } from 'twenty-ui/theme-constants'; - -// Absolutely positioned in the gap above its (position: relative) parent so -// activating the drop target does not reflow the list. -const StyledDropLineContainer = styled.div` - left: 0; - position: absolute; - right: 0; - top: calc(-1 * ${themeCssVariables.spacing[2]}); -`; - -const StyledDropLine = styled.div` - background-color: ${themeCssVariables.color.blue}; - border-radius: ${themeCssVariables.border.radius.sm}; - height: 2px; - width: 100%; -`; - -export const PageLayoutWidgetDropLine = () => ( - - - -); diff --git a/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutWidgetSortableItem.tsx b/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutWidgetSortableItem.tsx deleted file mode 100644 index 79fa45c06a..0000000000 --- a/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutWidgetSortableItem.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { SortableKeyboardPlugin } from '@dnd-kit/dom/sortable'; -import { useSortable } from '@dnd-kit/react/sortable'; -import { styled } from '@linaria/react'; -import { type ReactNode } from 'react'; -import { themeCssVariables } from 'twenty-ui/theme-constants'; - -import { PageLayoutWidgetDropLine } from '@/page-layout/components/dnd/PageLayoutWidgetDropLine'; -import { type PageLayoutWidgetDragData } from '@/page-layout/types/PageLayoutWidgetDndData'; -import { preventNativeDragStart } from '@/ui/utilities/drag-and-drop/utils/preventNativeDragStart'; - -const PLUGINS_WITHOUT_OPTIMISTIC = [SortableKeyboardPlugin]; - -const StyledSortableRoot = styled.div<{ isDragging: boolean }>` - background: ${({ isDragging }) => - isDragging - ? themeCssVariables.background.transparent.light - : 'transparent'}; - border-radius: ${themeCssVariables.border.radius.sm}; - min-height: 0; - position: relative; - transition: background 0.1s ease; -`; - -type PageLayoutWidgetSortableItemProps = { - widgetId: string; - tabId: string; - index: number; - children: ReactNode; -}; - -export const PageLayoutWidgetSortableItem = ({ - widgetId, - tabId, - index, - children, -}: PageLayoutWidgetSortableItemProps) => { - const data: PageLayoutWidgetDragData = { - type: 'widget', - widgetId, - tabId, - index, - }; - - const { ref, isDragging, isDropTarget } = useSortable({ - id: widgetId, - index, - group: tabId, - data, - transition: null, - plugins: PLUGINS_WITHOUT_OPTIMISTIC, - feedback: 'clone', - }); - - return ( - - {isDropTarget && } - {children} - - ); -}; diff --git a/packages/twenty-front/src/modules/page-layout/constants/PageLayoutTabDndType.ts b/packages/twenty-front/src/modules/page-layout/constants/PageLayoutTabDndType.ts new file mode 100644 index 0000000000..9f074ea18d --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/constants/PageLayoutTabDndType.ts @@ -0,0 +1 @@ +export const PAGE_LAYOUT_TAB_DND_TYPE = 'page-layout-tab'; diff --git a/packages/twenty-front/src/modules/page-layout/constants/PageLayoutTabDropTargetDataAttribute.ts b/packages/twenty-front/src/modules/page-layout/constants/PageLayoutTabDropTargetDataAttribute.ts new file mode 100644 index 0000000000..26c97437cc --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/constants/PageLayoutTabDropTargetDataAttribute.ts @@ -0,0 +1,2 @@ +export const PAGE_LAYOUT_TAB_DROP_TARGET_DATA_ATTRIBUTE = + 'data-page-layout-tab-drop-target-id'; diff --git a/packages/twenty-front/src/modules/page-layout/constants/PageLayoutTabListEndDropZoneWidth.ts b/packages/twenty-front/src/modules/page-layout/constants/PageLayoutTabListEndDropZoneWidth.ts new file mode 100644 index 0000000000..1261fdcf52 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/constants/PageLayoutTabListEndDropZoneWidth.ts @@ -0,0 +1,3 @@ +import { TAB_LIST_GAP } from '@/ui/layout/tab-list/constants/TabListGap'; + +export const PAGE_LAYOUT_TAB_LIST_END_DROP_ZONE_WIDTH = TAB_LIST_GAP * 2; diff --git a/packages/twenty-front/src/modules/page-layout/constants/PageLayoutWidgetDndType.ts b/packages/twenty-front/src/modules/page-layout/constants/PageLayoutWidgetDndType.ts new file mode 100644 index 0000000000..a95ec5ab35 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/constants/PageLayoutWidgetDndType.ts @@ -0,0 +1 @@ +export const PAGE_LAYOUT_WIDGET_DND_TYPE = 'page-layout-widget'; diff --git a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useCanMovePageLayoutWidgetDown.test.tsx b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useCanMovePageLayoutWidgetDown.test.tsx index 540c6f758a..59e410ebc4 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useCanMovePageLayoutWidgetDown.test.tsx +++ b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useCanMovePageLayoutWidgetDown.test.tsx @@ -1,70 +1,19 @@ import { useCanMovePageLayoutWidgetDown } from '@/page-layout/hooks/useCanMovePageLayoutWidgetDown'; import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; -import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout'; -import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { + makeDraft, + makeTab, + makeWidget, +} from '@/page-layout/testing/pageLayoutDraftFixtures'; import { renderHook } from '@testing-library/react'; import { createStore } from 'jotai'; import { type ReactNode } from 'react'; -import { - PageLayoutTabLayoutMode, - PageLayoutType, - WidgetType, -} from '~/generated-metadata/graphql'; +import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql'; import { PAGE_LAYOUT_TEST_INSTANCE_ID, PageLayoutTestWrapper, } from './PageLayoutTestWrapper'; -const makeWidget = ( - id: string, - index: number, - tabId: string = 'tab-1', -): PageLayoutWidget => - ({ - id, - pageLayoutTabId: tabId, - title: id, - isActive: true, - type: WidgetType.FIELDS, - gridPosition: { column: 0, columnSpan: 1, row: 0, rowSpan: 1 }, - configuration: { __typename: 'FieldsConfiguration' as const }, - position: { - __typename: 'PageLayoutWidgetVerticalListPosition' as const, - layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, - index, - }, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - deletedAt: null, - }) as unknown as PageLayoutWidget; - -const makeTab = ( - id: string, - widgets: PageLayoutWidget[], - position: number = 0, - layoutMode: PageLayoutTabLayoutMode = PageLayoutTabLayoutMode.VERTICAL_LIST, -) => ({ - id, - applicationId: '', - title: id, - isActive: true, - position, - layoutMode, - pageLayoutId: '', - widgets, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - deletedAt: null, -}); - -const makeDraft = (tabs: ReturnType[]): DraftPageLayout => ({ - id: 'test-layout', - name: 'Test Layout', - type: PageLayoutType.RECORD_PAGE, - objectMetadataId: null, - tabs, -}); - describe('useCanMovePageLayoutWidgetDown', () => { const getWrapper = (store = createStore()) => diff --git a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useCanMovePageLayoutWidgetUp.test.tsx b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useCanMovePageLayoutWidgetUp.test.tsx index 6874e43338..a69b2f9bb2 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useCanMovePageLayoutWidgetUp.test.tsx +++ b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useCanMovePageLayoutWidgetUp.test.tsx @@ -1,70 +1,19 @@ import { useCanMovePageLayoutWidgetUp } from '@/page-layout/hooks/useCanMovePageLayoutWidgetUp'; import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; -import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout'; -import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { + makeDraft, + makeTab, + makeWidget, +} from '@/page-layout/testing/pageLayoutDraftFixtures'; import { renderHook } from '@testing-library/react'; import { createStore } from 'jotai'; import { type ReactNode } from 'react'; -import { - PageLayoutTabLayoutMode, - PageLayoutType, - WidgetType, -} from '~/generated-metadata/graphql'; +import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql'; import { PAGE_LAYOUT_TEST_INSTANCE_ID, PageLayoutTestWrapper, } from './PageLayoutTestWrapper'; -const makeWidget = ( - id: string, - index: number, - tabId: string = 'tab-1', -): PageLayoutWidget => - ({ - id, - pageLayoutTabId: tabId, - title: id, - isActive: true, - type: WidgetType.FIELDS, - gridPosition: { column: 0, columnSpan: 1, row: 0, rowSpan: 1 }, - configuration: { __typename: 'FieldsConfiguration' as const }, - position: { - __typename: 'PageLayoutWidgetVerticalListPosition' as const, - layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, - index, - }, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - deletedAt: null, - }) as unknown as PageLayoutWidget; - -const makeTab = ( - id: string, - widgets: PageLayoutWidget[], - position: number = 0, - layoutMode: PageLayoutTabLayoutMode = PageLayoutTabLayoutMode.VERTICAL_LIST, -) => ({ - id, - applicationId: '', - title: id, - isActive: true, - position, - layoutMode, - pageLayoutId: '', - widgets, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - deletedAt: null, -}); - -const makeDraft = (tabs: ReturnType[]): DraftPageLayout => ({ - id: 'test-layout', - name: 'Test Layout', - type: PageLayoutType.RECORD_PAGE, - objectMetadataId: null, - tabs, -}); - describe('useCanMovePageLayoutWidgetUp', () => { const getWrapper = (store = createStore()) => diff --git a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useInsertCreatedWidgetAtContext.test.tsx b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useInsertCreatedWidgetAtContext.test.tsx index 5dfc18a1a8..e148685456 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useInsertCreatedWidgetAtContext.test.tsx +++ b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useInsertCreatedWidgetAtContext.test.tsx @@ -1,69 +1,20 @@ import { useInsertCreatedWidgetAtContext } from '@/page-layout/hooks/useInsertCreatedWidgetAtContext'; import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; import { widgetInsertionContextComponentState } from '@/page-layout/states/widgetInsertionContextComponentState'; -import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout'; -import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { + makeDraft, + makeTab, + makeWidget, +} from '@/page-layout/testing/pageLayoutDraftFixtures'; import { act, renderHook } from '@testing-library/react'; import { createStore } from 'jotai'; import { type ReactNode } from 'react'; -import { - PageLayoutTabLayoutMode, - PageLayoutType, - WidgetType, -} from '~/generated-metadata/graphql'; +import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql'; import { PAGE_LAYOUT_TEST_INSTANCE_ID, PageLayoutTestWrapper, } from './PageLayoutTestWrapper'; -const makeWidget = ( - id: string, - index: number, - tabId: string = 'tab-1', -): PageLayoutWidget => - ({ - id, - pageLayoutTabId: tabId, - title: id, - isActive: true, - type: WidgetType.FIELDS, - gridPosition: { column: 0, columnSpan: 1, row: 0, rowSpan: 1 }, - configuration: { __typename: 'FieldsConfiguration' as const }, - position: { - __typename: 'PageLayoutWidgetVerticalListPosition' as const, - layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, - index, - }, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - deletedAt: null, - }) as unknown as PageLayoutWidget; - -const makeTab = ( - id: string, - widgets: PageLayoutWidget[], - position: number = 0, -) => ({ - id, - applicationId: '', - title: id, - isActive: true, - position, - pageLayoutId: '', - widgets, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - deletedAt: null, -}); - -const makeDraft = (tabs: ReturnType[]): DraftPageLayout => ({ - id: 'test-layout', - name: 'Test Layout', - type: PageLayoutType.RECORD_PAGE, - objectMetadataId: null, - tabs, -}); - describe('useInsertCreatedWidgetAtContext', () => { const getWrapper = (store = createStore()) => diff --git a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useMovePageLayoutWidgetDown.test.tsx b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useMovePageLayoutWidgetDown.test.tsx index 6bc302e522..94f79092ae 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useMovePageLayoutWidgetDown.test.tsx +++ b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useMovePageLayoutWidgetDown.test.tsx @@ -1,69 +1,18 @@ import { useMovePageLayoutWidgetDown } from '@/page-layout/hooks/useMovePageLayoutWidgetDown'; import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; -import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout'; -import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { + makeDraft, + makeTab, + makeWidget, +} from '@/page-layout/testing/pageLayoutDraftFixtures'; import { act, renderHook } from '@testing-library/react'; import { createStore } from 'jotai'; import { type ReactNode } from 'react'; -import { - PageLayoutTabLayoutMode, - PageLayoutType, - WidgetType, -} from '~/generated-metadata/graphql'; import { PAGE_LAYOUT_TEST_INSTANCE_ID, PageLayoutTestWrapper, } from './PageLayoutTestWrapper'; -const makeWidget = ( - id: string, - index: number, - tabId: string = 'tab-1', -): PageLayoutWidget => - ({ - id, - pageLayoutTabId: tabId, - title: id, - isActive: true, - type: WidgetType.FIELDS, - gridPosition: { column: 0, columnSpan: 1, row: 0, rowSpan: 1 }, - configuration: { __typename: 'FieldsConfiguration' as const }, - position: { - __typename: 'PageLayoutWidgetVerticalListPosition' as const, - layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, - index, - }, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - deletedAt: null, - }) as unknown as PageLayoutWidget; - -const makeTab = ( - id: string, - widgets: PageLayoutWidget[], - position: number = 0, -) => ({ - id, - applicationId: '', - title: id, - isActive: true, - position, - layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, - pageLayoutId: '', - widgets, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - deletedAt: null, -}); - -const makeDraft = (tabs: ReturnType[]): DraftPageLayout => ({ - id: 'test-layout', - name: 'Test Layout', - type: PageLayoutType.RECORD_PAGE, - objectMetadataId: null, - tabs, -}); - describe('useMovePageLayoutWidgetDown', () => { const getWrapper = (store = createStore()) => diff --git a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useMovePageLayoutWidgetUp.test.tsx b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useMovePageLayoutWidgetUp.test.tsx index 1a04cef1f2..be36267519 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useMovePageLayoutWidgetUp.test.tsx +++ b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useMovePageLayoutWidgetUp.test.tsx @@ -1,69 +1,18 @@ import { useMovePageLayoutWidgetUp } from '@/page-layout/hooks/useMovePageLayoutWidgetUp'; import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; -import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout'; -import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { + makeDraft, + makeTab, + makeWidget, +} from '@/page-layout/testing/pageLayoutDraftFixtures'; import { act, renderHook } from '@testing-library/react'; import { createStore } from 'jotai'; import { type ReactNode } from 'react'; -import { - PageLayoutTabLayoutMode, - PageLayoutType, - WidgetType, -} from '~/generated-metadata/graphql'; import { PAGE_LAYOUT_TEST_INSTANCE_ID, PageLayoutTestWrapper, } from './PageLayoutTestWrapper'; -const makeWidget = ( - id: string, - index: number, - tabId: string = 'tab-1', -): PageLayoutWidget => - ({ - id, - pageLayoutTabId: tabId, - title: id, - isActive: true, - type: WidgetType.FIELDS, - gridPosition: { column: 0, columnSpan: 1, row: 0, rowSpan: 1 }, - configuration: { __typename: 'FieldsConfiguration' as const }, - position: { - __typename: 'PageLayoutWidgetVerticalListPosition' as const, - layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, - index, - }, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - deletedAt: null, - }) as unknown as PageLayoutWidget; - -const makeTab = ( - id: string, - widgets: PageLayoutWidget[], - position: number = 0, -) => ({ - id, - applicationId: '', - title: id, - isActive: true, - position, - layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, - pageLayoutId: '', - widgets, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - deletedAt: null, -}); - -const makeDraft = (tabs: ReturnType[]): DraftPageLayout => ({ - id: 'test-layout', - name: 'Test Layout', - type: PageLayoutType.RECORD_PAGE, - objectMetadataId: null, - tabs, -}); - describe('useMovePageLayoutWidgetUp', () => { const getWrapper = (store = createStore()) => diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useMovePageLayoutWidgetDown.ts b/packages/twenty-front/src/modules/page-layout/hooks/useMovePageLayoutWidgetDown.ts index 2eb1c2faf4..64065f1b1b 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useMovePageLayoutWidgetDown.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useMovePageLayoutWidgetDown.ts @@ -1,13 +1,11 @@ import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; -import { isVerticalListPosition } from '@/page-layout/utils/isVerticalListPosition'; +import { moveWidgetWithinTabInDraft } from '@/page-layout/utils/moveWidgetWithinTabInDraft'; import { sortWidgetsByVerticalListPosition } from '@/page-layout/utils/sortWidgetsByVerticalListPosition'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; import { useStore } from 'jotai'; import { useCallback } from 'react'; -import { isDefined } from 'twenty-shared/utils'; -import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql'; export const useMovePageLayoutWidgetDown = (pageLayoutIdFromProps?: string) => { const pageLayoutId = useAvailableComponentInstanceIdOrThrow( @@ -34,7 +32,6 @@ export const useMovePageLayoutWidgetDown = (pageLayoutIdFromProps?: string) => { } const sortedWidgets = sortWidgetsByVerticalListPosition(tab.widgets); - const currentIndex = sortedWidgets.findIndex( (widget) => widget.id === widgetId, ); @@ -43,56 +40,11 @@ export const useMovePageLayoutWidgetDown = (pageLayoutIdFromProps?: string) => { return prev; } - const currentWidget = sortedWidgets[currentIndex]; - const neighborWidget = sortedWidgets[currentIndex + 1]; - - const currentPositionIndex = - isDefined(currentWidget.position) && - isVerticalListPosition(currentWidget.position) - ? currentWidget.position.index - : currentIndex; - const neighborPositionIndex = - isDefined(neighborWidget.position) && - isVerticalListPosition(neighborWidget.position) - ? neighborWidget.position.index - : currentIndex + 1; - - return { - ...prev, - tabs: prev.tabs.map((currentTab) => { - if (currentTab.id !== tab.id) { - return currentTab; - } - return { - ...currentTab, - widgets: currentTab.widgets.map((widget) => { - if (widget.id === currentWidget.id) { - return { - ...widget, - position: { - __typename: - 'PageLayoutWidgetVerticalListPosition' as const, - layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, - index: neighborPositionIndex, - }, - }; - } - if (widget.id === neighborWidget.id) { - return { - ...widget, - position: { - __typename: - 'PageLayoutWidgetVerticalListPosition' as const, - layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, - index: currentPositionIndex, - }, - }; - } - return widget; - }), - }; - }), - }; + return moveWidgetWithinTabInDraft(prev, { + tabId: tab.id, + fromIndex: currentIndex, + toIndex: currentIndex + 1, + }); }); }, [pageLayoutDraftState, store], diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useMovePageLayoutWidgetUp.ts b/packages/twenty-front/src/modules/page-layout/hooks/useMovePageLayoutWidgetUp.ts index bce10b9c41..5d524b854f 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useMovePageLayoutWidgetUp.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useMovePageLayoutWidgetUp.ts @@ -1,13 +1,11 @@ import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; -import { isVerticalListPosition } from '@/page-layout/utils/isVerticalListPosition'; +import { moveWidgetWithinTabInDraft } from '@/page-layout/utils/moveWidgetWithinTabInDraft'; import { sortWidgetsByVerticalListPosition } from '@/page-layout/utils/sortWidgetsByVerticalListPosition'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; import { useStore } from 'jotai'; import { useCallback } from 'react'; -import { isDefined } from 'twenty-shared/utils'; -import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql'; export const useMovePageLayoutWidgetUp = (pageLayoutIdFromProps?: string) => { const pageLayoutId = useAvailableComponentInstanceIdOrThrow( @@ -33,66 +31,19 @@ export const useMovePageLayoutWidgetUp = (pageLayoutIdFromProps?: string) => { return prev; } - const sortedWidgets = sortWidgetsByVerticalListPosition(tab.widgets); - - const currentIndex = sortedWidgets.findIndex( - (widget) => widget.id === widgetId, - ); + const currentIndex = sortWidgetsByVerticalListPosition( + tab.widgets, + ).findIndex((widget) => widget.id === widgetId); if (currentIndex <= 0) { return prev; } - const currentWidget = sortedWidgets[currentIndex]; - const neighborWidget = sortedWidgets[currentIndex - 1]; - - const currentPositionIndex = - isDefined(currentWidget.position) && - isVerticalListPosition(currentWidget.position) - ? currentWidget.position.index - : currentIndex; - const neighborPositionIndex = - isDefined(neighborWidget.position) && - isVerticalListPosition(neighborWidget.position) - ? neighborWidget.position.index - : currentIndex - 1; - - return { - ...prev, - tabs: prev.tabs.map((currentTab) => { - if (currentTab.id !== tab.id) { - return currentTab; - } - return { - ...currentTab, - widgets: currentTab.widgets.map((widget) => { - if (widget.id === currentWidget.id) { - return { - ...widget, - position: { - __typename: - 'PageLayoutWidgetVerticalListPosition' as const, - layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, - index: neighborPositionIndex, - }, - }; - } - if (widget.id === neighborWidget.id) { - return { - ...widget, - position: { - __typename: - 'PageLayoutWidgetVerticalListPosition' as const, - layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, - index: currentPositionIndex, - }, - }; - } - return widget; - }), - }; - }), - }; + return moveWidgetWithinTabInDraft(prev, { + tabId: tab.id, + fromIndex: currentIndex, + toIndex: currentIndex - 1, + }); }); }, [pageLayoutDraftState, store], diff --git a/packages/twenty-front/src/modules/page-layout/hooks/usePageLayoutGridCrossTabDrop.ts b/packages/twenty-front/src/modules/page-layout/hooks/usePageLayoutGridCrossTabDrop.ts new file mode 100644 index 0000000000..0777f795d4 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/hooks/usePageLayoutGridCrossTabDrop.ts @@ -0,0 +1,183 @@ +import { useStore } from 'jotai'; +import { useCallback } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql'; + +import { PAGE_LAYOUT_TAB_DROP_TARGET_DATA_ATTRIBUTE } from '@/page-layout/constants/PageLayoutTabDropTargetDataAttribute'; +import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; +import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState'; +import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; +import { pageLayoutGridDragHoveredTabIdComponentState } from '@/page-layout/states/pageLayoutGridDragHoveredTabIdComponentState'; +import { pageLayoutShouldIgnoreNextGridLayoutChangeComponentState } from '@/page-layout/states/pageLayoutShouldIgnoreNextGridLayoutChangeComponentState'; +import { buildTabWidgetLayouts } from '@/page-layout/utils/buildTabWidgetLayouts'; +import { moveWidgetToGridTabInDraft } from '@/page-layout/utils/moveWidgetToGridTabInDraft'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; + +const findTabDropTargetIdAtPoint = ( + clientX: number, + clientY: number, +): string | null => { + const dropTargetElements = document.querySelectorAll( + `[${PAGE_LAYOUT_TAB_DROP_TARGET_DATA_ATTRIBUTE}]`, + ); + + for (const dropTargetElement of dropTargetElements) { + const rect = dropTargetElement.getBoundingClientRect(); + + if ( + clientX >= rect.left && + clientX <= rect.right && + clientY >= rect.top && + clientY <= rect.bottom + ) { + return dropTargetElement.getAttribute( + PAGE_LAYOUT_TAB_DROP_TARGET_DATA_ATTRIBUTE, + ); + } + } + + return null; +}; + +// Bridges react-grid-layout drags to the tab strip: grid drags never enter +// dnd-kit, so hovering and dropping on tab buttons is resolved by hit-testing +// the pointer against the tab drop targets. +export const usePageLayoutGridCrossTabDrop = ({ tabId }: { tabId: string }) => { + const pageLayoutId = useAvailableComponentInstanceIdOrThrow( + PageLayoutComponentInstanceContext, + ); + + const store = useStore(); + + const pageLayoutDraftState = useAtomComponentStateCallbackState( + pageLayoutDraftComponentState, + pageLayoutId, + ); + + const pageLayoutCurrentLayoutsState = useAtomComponentStateCallbackState( + pageLayoutCurrentLayoutsComponentState, + pageLayoutId, + ); + + const gridDragHoveredTabIdState = useAtomComponentStateCallbackState( + pageLayoutGridDragHoveredTabIdComponentState, + pageLayoutId, + ); + + const shouldIgnoreNextGridLayoutChangeState = + useAtomComponentStateCallbackState( + pageLayoutShouldIgnoreNextGridLayoutChangeComponentState, + pageLayoutId, + ); + + const findGridDropDestinationTabId = useCallback( + (clientX: number, clientY: number): string | null => { + const hoveredTabId = findTabDropTargetIdAtPoint(clientX, clientY); + + if (!isDefined(hoveredTabId) || hoveredTabId === tabId) { + return null; + } + + const draft = store.get(pageLayoutDraftState); + const destinationTab = draft.tabs.find((tab) => tab.id === hoveredTabId); + + if ( + !isDefined(destinationTab) || + destinationTab.layoutMode === PageLayoutTabLayoutMode.VERTICAL_LIST + ) { + return null; + } + + return hoveredTabId; + }, + [store, pageLayoutDraftState, tabId], + ); + + const handleGridDrag = useCallback( + (event: MouseEvent) => { + const destinationTabId = findGridDropDestinationTabId( + event.clientX, + event.clientY, + ); + + if (store.get(gridDragHoveredTabIdState) !== destinationTabId) { + store.set(gridDragHoveredTabIdState, destinationTabId); + } + }, + [store, gridDragHoveredTabIdState, findGridDropDestinationTabId], + ); + + const handleGridDragStop = useCallback( + (widgetId: string, event: MouseEvent): boolean => { + store.set(gridDragHoveredTabIdState, null); + + const destinationTabId = findGridDropDestinationTabId( + event.clientX, + event.clientY, + ); + + if (!isDefined(destinationTabId)) { + return false; + } + + const previousDraft = store.get(pageLayoutDraftState); + const updatedDraft = moveWidgetToGridTabInDraft(previousDraft, { + widgetId, + destinationTabId, + }); + + if (updatedDraft === previousDraft) { + return false; + } + + store.set(pageLayoutDraftState, updatedDraft); + + const sourceTab = updatedDraft.tabs.find((tab) => tab.id === tabId); + const destinationTab = updatedDraft.tabs.find( + (tab) => tab.id === destinationTabId, + ); + + store.set(pageLayoutCurrentLayoutsState, (previousLayouts) => ({ + ...previousLayouts, + ...(isDefined(sourceTab) + ? { [tabId]: buildTabWidgetLayouts(sourceTab.widgets) } + : {}), + ...(isDefined(destinationTab) + ? { + [destinationTabId]: buildTabWidgetLayouts(destinationTab.widgets), + } + : {}), + })); + + store.set(shouldIgnoreNextGridLayoutChangeState, true); + + return true; + }, + [ + store, + gridDragHoveredTabIdState, + findGridDropDestinationTabId, + pageLayoutDraftState, + pageLayoutCurrentLayoutsState, + shouldIgnoreNextGridLayoutChangeState, + tabId, + ], + ); + + const consumeShouldIgnoreNextGridLayoutChange = useCallback((): boolean => { + const shouldIgnore = store.get(shouldIgnoreNextGridLayoutChangeState); + + if (shouldIgnore) { + store.set(shouldIgnoreNextGridLayoutChangeState, false); + } + + return shouldIgnore; + }, [store, shouldIgnoreNextGridLayoutChangeState]); + + return { + handleGridDrag, + handleGridDragStop, + consumeShouldIgnoreNextGridLayoutChange, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/hooks/usePageLayoutWidgetDragAndDrop.ts b/packages/twenty-front/src/modules/page-layout/hooks/usePageLayoutWidgetDragAndDrop.ts index 6dbd8ff5bd..b6eda53f2d 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/usePageLayoutWidgetDragAndDrop.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/usePageLayoutWidgetDragAndDrop.ts @@ -1,6 +1,5 @@ -import { type DragDropProvider } from '@dnd-kit/react'; import { useStore } from 'jotai'; -import { type ComponentProps, useCallback } from 'react'; +import { useCallback } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; @@ -9,18 +8,16 @@ import { pageLayoutDraggingWidgetIdComponentState } from '@/page-layout/states/p import { type PageLayoutWidgetDndData } from '@/page-layout/types/PageLayoutWidgetDndData'; import { moveWidgetToTabInDraft } from '@/page-layout/utils/moveWidgetToTabInDraft'; import { moveWidgetWithinTabInDraft } from '@/page-layout/utils/moveWidgetWithinTabInDraft'; +import { reorderTabInDraft } from '@/page-layout/utils/reorderTabInDraft'; +import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; +import { type DragDropProviderDragStartEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragStartEvent'; import { getDestinationIndex } from '@/ui/utilities/drag-and-drop/utils/getDestinationIndex'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState'; -type Provider = typeof DragDropProvider; -type DragStartEvent = Parameters< - NonNullable['onDragStart']> ->[0]; -type DragEndEvent = Parameters< - NonNullable['onDragEnd']> ->[0]; +type DragStartEvent = DragDropProviderDragStartEvent; +type DragEndEvent = DragDropProviderDragEndEvent; export const usePageLayoutWidgetDragAndDrop = ( pageLayoutIdFromProps?: string, @@ -130,6 +127,31 @@ export const usePageLayoutWidgetDragAndDrop = ( } } + if ( + !event.canceled && + sourceData?.type === 'tab' && + isDefined(targetData) + ) { + const draggedTabId = sourceData.tabId; + + // The drop line renders before the hovered tab, so tab targets insert + // the dragged tab before them; end zones and the more button append. + const beforeTabId = + targetData.type === 'tab' + ? targetData.tabId + : targetData.type === 'tab-list-end' + ? targetData.beforeTabId + : targetData.type === 'tab-more-button' + ? null + : undefined; + + if (beforeTabId !== undefined) { + store.set(pageLayoutDraftState, (prev) => + reorderTabInDraft(prev, { tabId: draggedTabId, beforeTabId }), + ); + } + } + setPageLayoutDraggingWidgetId(null); }, [store, pageLayoutDraftState, setPageLayoutDraggingWidgetId], diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useReorderPageLayoutTabs.ts b/packages/twenty-front/src/modules/page-layout/hooks/useReorderPageLayoutTabs.ts deleted file mode 100644 index efd7de109b..0000000000 --- a/packages/twenty-front/src/modules/page-layout/hooks/useReorderPageLayoutTabs.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS } from '@/page-layout/components/PageLayoutTabListDroppableIds'; -import { useCurrentPageLayout } from '@/page-layout/hooks/useCurrentPageLayout'; -import { usePageLayoutDraftState } from '@/page-layout/hooks/usePageLayoutDraftState'; -import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; -import { sortTabsByPosition } from '@/page-layout/utils/sortTabsByPosition'; -import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; -import { type DropResult } from '@hello-pangea/dnd'; -import { useCallback } from 'react'; -import { isDefined } from 'twenty-shared/utils'; - -export const useReorderPageLayoutTabs = (pageLayoutIdFromProps?: string) => { - const pageLayoutId = useAvailableComponentInstanceIdOrThrow( - PageLayoutComponentInstanceContext, - pageLayoutIdFromProps, - ); - - const { currentPageLayout } = useCurrentPageLayout(); - const { setPageLayoutDraft } = usePageLayoutDraftState(pageLayoutId); - - const reorderTabs = useCallback( - (result: DropResult): boolean => { - const { source, destination, draggableId } = result; - - if (!isDefined(destination) || !isDefined(currentPageLayout)) { - return false; - } - - if ( - source.droppableId === destination.droppableId && - source.index === destination.index - ) { - return false; - } - - const sortedTabs = sortTabsByPosition( - currentPageLayout.tabs.filter((tab) => tab.isActive), - ); - - const draggedTab = sortedTabs.find((tab) => tab.id === draggableId); - if (!isDefined(draggedTab)) { - return false; - } - - const isDropOnMoreButton = - destination.droppableId === - PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.MORE_BUTTON; - - const orderedIds = sortedTabs - .map((tab) => tab.id) - .filter((id) => id !== draggableId); - - const movingBetweenDroppables = - source.droppableId !== destination.droppableId; - - const insertIndex = isDropOnMoreButton - ? orderedIds.length - : movingBetweenDroppables && destination.index > source.index - ? destination.index - 1 - : destination.index; - - orderedIds.splice(insertIndex, 0, draggableId); - - const newPositionById = new Map( - orderedIds.map((id, index) => [id, index]), - ); - - setPageLayoutDraft((prev) => ({ - ...prev, - tabs: prev.tabs.map((tab) => { - const newPosition = newPositionById.get(tab.id); - return isDefined(newPosition) - ? { ...tab, position: newPosition } - : tab; - }), - })); - - return isDropOnMoreButton; - }, - [currentPageLayout, setPageLayoutDraft], - ); - - return { reorderTabs }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useReorderRecordPageLayoutTabs.ts b/packages/twenty-front/src/modules/page-layout/hooks/useReorderRecordPageLayoutTabs.ts deleted file mode 100644 index fd3baed44e..0000000000 --- a/packages/twenty-front/src/modules/page-layout/hooks/useReorderRecordPageLayoutTabs.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { useReorderPageLayoutTabs } from '@/page-layout/hooks/useReorderPageLayoutTabs'; -import { type DropResult, type ResponderProvided } from '@hello-pangea/dnd'; -import { useCallback } from 'react'; -import { isDefined } from 'twenty-shared/utils'; - -export const useReorderRecordPageLayoutTabs = ( - pageLayoutIdFromProps?: string, -) => { - const { reorderTabs } = useReorderPageLayoutTabs(pageLayoutIdFromProps); - - const reorderRecordPageTabs = useCallback( - ( - result: DropResult, - provided: ResponderProvided, - hasPinnedTab: boolean, - ): boolean => { - if (!hasPinnedTab) { - return reorderTabs(result); - } - - const { source, destination } = result; - - if (!isDefined(destination)) { - return reorderTabs(result); - } - - const adjustedResult: DropResult = { - ...result, - source: { - ...source, - index: source.index + 1, - }, - destination: { - ...destination, - index: destination.index + 1, - }, - }; - - return reorderTabs(adjustedResult); - }, - [reorderTabs], - ); - - return { reorderRecordPageTabs }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/states/pageLayoutGridDragHoveredTabIdComponentState.ts b/packages/twenty-front/src/modules/page-layout/states/pageLayoutGridDragHoveredTabIdComponentState.ts new file mode 100644 index 0000000000..3a647309d8 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/states/pageLayoutGridDragHoveredTabIdComponentState.ts @@ -0,0 +1,11 @@ +import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; +import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState'; + +// Tab hovered by a react-grid-layout widget drag; grid drags never enter +// dnd-kit, so the tab highlight is driven through this state instead. +export const pageLayoutGridDragHoveredTabIdComponentState = + createAtomComponentState({ + key: 'pageLayoutGridDragHoveredTabIdComponentState', + defaultValue: null, + componentInstanceContext: PageLayoutComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/page-layout/states/pageLayoutShouldIgnoreNextGridLayoutChangeComponentState.ts b/packages/twenty-front/src/modules/page-layout/states/pageLayoutShouldIgnoreNextGridLayoutChangeComponentState.ts new file mode 100644 index 0000000000..7aa21f2d3e --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/states/pageLayoutShouldIgnoreNextGridLayoutChangeComponentState.ts @@ -0,0 +1,12 @@ +import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; +import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState'; + +// A grid drop routed to another tab rebuilds both tabs' layouts itself; the +// grid's own post-drag layout commit must be skipped once so it does not +// overwrite the cross-tab move. +export const pageLayoutShouldIgnoreNextGridLayoutChangeComponentState = + createAtomComponentState({ + key: 'pageLayoutShouldIgnoreNextGridLayoutChangeComponentState', + defaultValue: false, + componentInstanceContext: PageLayoutComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/page-layout/states/pageLayoutTabListCurrentDragDroppableIdComponentState.ts b/packages/twenty-front/src/modules/page-layout/states/pageLayoutTabListCurrentDragDroppableIdComponentState.ts deleted file mode 100644 index cbbcbbaab4..0000000000 --- a/packages/twenty-front/src/modules/page-layout/states/pageLayoutTabListCurrentDragDroppableIdComponentState.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState'; - -import { PageLayoutComponentInstanceContext } from './contexts/PageLayoutComponentInstanceContext'; - -export const pageLayoutTabListCurrentDragDroppableIdComponentState = - createAtomComponentState({ - key: 'pageLayoutTabListCurrentDragDroppableIdComponentState', - defaultValue: undefined, - componentInstanceContext: PageLayoutComponentInstanceContext, - }); diff --git a/packages/twenty-front/src/modules/page-layout/testing/pageLayoutDraftFixtures.ts b/packages/twenty-front/src/modules/page-layout/testing/pageLayoutDraftFixtures.ts index e27436c738..17e5c12610 100644 --- a/packages/twenty-front/src/modules/page-layout/testing/pageLayoutDraftFixtures.ts +++ b/packages/twenty-front/src/modules/page-layout/testing/pageLayoutDraftFixtures.ts @@ -1,4 +1,5 @@ import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout'; +import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab'; import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; import { PageLayoutTabLayoutMode, @@ -34,6 +35,7 @@ export const makeTab = ( widgets: PageLayoutWidget[], position = 0, layoutMode: PageLayoutTabLayoutMode = PageLayoutTabLayoutMode.VERTICAL_LIST, + overrides?: Partial, ) => ({ id, applicationId: '', @@ -46,6 +48,7 @@ export const makeTab = ( createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), deletedAt: null, + ...overrides, }); export const makeDraft = ( diff --git a/packages/twenty-front/src/modules/page-layout/types/PageLayoutTabDragData.ts b/packages/twenty-front/src/modules/page-layout/types/PageLayoutTabDragData.ts new file mode 100644 index 0000000000..a4f6579915 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/types/PageLayoutTabDragData.ts @@ -0,0 +1,4 @@ +export type PageLayoutTabDragData = { + type: 'tab'; + tabId: string; +}; diff --git a/packages/twenty-front/src/modules/page-layout/types/PageLayoutTabListEndDropData.ts b/packages/twenty-front/src/modules/page-layout/types/PageLayoutTabListEndDropData.ts new file mode 100644 index 0000000000..ec069af1e8 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/types/PageLayoutTabListEndDropData.ts @@ -0,0 +1,5 @@ +// beforeTabId null means append after the last tab. +export type PageLayoutTabListEndDropData = { + type: 'tab-list-end'; + beforeTabId: string | null; +}; diff --git a/packages/twenty-front/src/modules/page-layout/types/PageLayoutTabMoreButtonDropData.ts b/packages/twenty-front/src/modules/page-layout/types/PageLayoutTabMoreButtonDropData.ts new file mode 100644 index 0000000000..77f2eb2ee1 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/types/PageLayoutTabMoreButtonDropData.ts @@ -0,0 +1,3 @@ +export type PageLayoutTabMoreButtonDropData = { + type: 'tab-more-button'; +}; diff --git a/packages/twenty-front/src/modules/page-layout/types/PageLayoutTabWidgetDropData.ts b/packages/twenty-front/src/modules/page-layout/types/PageLayoutTabWidgetDropData.ts new file mode 100644 index 0000000000..633674e72b --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/types/PageLayoutTabWidgetDropData.ts @@ -0,0 +1,4 @@ +export type PageLayoutTabWidgetDropData = { + type: 'tab-widget-drop'; + tabId: string; +}; diff --git a/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidgetDndData.ts b/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidgetDndData.ts index 32253a537b..61640bcba9 100644 --- a/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidgetDndData.ts +++ b/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidgetDndData.ts @@ -1,21 +1,14 @@ -export type PageLayoutWidgetDragData = { - type: 'widget'; - widgetId: string; - tabId: string; - index: number; -}; - -export type PageLayoutTabWidgetDropData = { - type: 'tab-widget-drop'; - tabId: string; -}; - -export type PageLayoutWidgetListDropData = { - type: 'widget-list'; - tabId: string; -}; +import { type PageLayoutTabDragData } from '@/page-layout/types/PageLayoutTabDragData'; +import { type PageLayoutTabListEndDropData } from '@/page-layout/types/PageLayoutTabListEndDropData'; +import { type PageLayoutTabMoreButtonDropData } from '@/page-layout/types/PageLayoutTabMoreButtonDropData'; +import { type PageLayoutTabWidgetDropData } from '@/page-layout/types/PageLayoutTabWidgetDropData'; +import { type PageLayoutWidgetDragData } from '@/page-layout/types/PageLayoutWidgetDragData'; +import { type PageLayoutWidgetListDropData } from '@/page-layout/types/PageLayoutWidgetListDropData'; export type PageLayoutWidgetDndData = | PageLayoutWidgetDragData | PageLayoutTabWidgetDropData - | PageLayoutWidgetListDropData; + | PageLayoutWidgetListDropData + | PageLayoutTabDragData + | PageLayoutTabListEndDropData + | PageLayoutTabMoreButtonDropData; diff --git a/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidgetDragData.ts b/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidgetDragData.ts new file mode 100644 index 0000000000..fa9c30030d --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidgetDragData.ts @@ -0,0 +1,6 @@ +export type PageLayoutWidgetDragData = { + type: 'widget'; + widgetId: string; + tabId: string; + index: number; +}; diff --git a/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidgetListDropData.ts b/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidgetListDropData.ts new file mode 100644 index 0000000000..8086921715 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidgetListDropData.ts @@ -0,0 +1,4 @@ +export type PageLayoutWidgetListDropData = { + type: 'widget-list'; + tabId: string; +}; diff --git a/packages/twenty-front/src/modules/page-layout/utils/__tests__/convertPageLayoutToTabLayouts.test.ts b/packages/twenty-front/src/modules/page-layout/utils/__tests__/convertPageLayoutToTabLayouts.test.ts index 029c249372..bcfe9b86cf 100644 --- a/packages/twenty-front/src/modules/page-layout/utils/__tests__/convertPageLayoutToTabLayouts.test.ts +++ b/packages/twenty-front/src/modules/page-layout/utils/__tests__/convertPageLayoutToTabLayouts.test.ts @@ -212,4 +212,59 @@ describe('convertPageLayoutToTabLayouts', () => { minH: richTextMinSize.h, }); }); + + it('should use the widget-type minimum size for iframe widgets', () => { + const pageLayout: PageLayout = { + id: 'page-layout-1', + name: 'Page Layout 1', + type: PageLayoutType.DASHBOARD, + objectMetadataId: null, + universalIdentifier: '20202020-0000-0000-0000-000000000001', + tabs: [ + { + id: 'tab-1', + applicationId: '', + isActive: true, + title: 'Tab 1', + position: 0, + pageLayoutId: 'page-layout-1', + widgets: [ + { + __typename: 'PageLayoutWidget', + id: 'iframe-widget', + applicationId: '', + isActive: true, + pageLayoutTabId: 'tab-1', + title: 'Iframe', + type: WidgetType.IFRAME, + configuration: { + configurationType: WidgetConfigurationType.IFRAME, + url: 'https://example.com', + }, + gridPosition: { row: 0, column: 0, rowSpan: 6, columnSpan: 6 }, + objectMetadataId: null, + createdAt: '2025-01-01T00:00:00.000Z', + updatedAt: '2025-01-01T00:00:00.000Z', + deletedAt: null, + }, + ], + createdAt: '2025-01-01T00:00:00.000Z', + updatedAt: '2025-01-01T00:00:00.000Z', + deletedAt: null, + }, + ], + createdAt: '2025-01-01T00:00:00.000Z', + updatedAt: '2025-01-01T00:00:00.000Z', + deletedAt: null, + }; + + const result = convertPageLayoutToTabLayouts(pageLayout); + const iframeMinSize = WIDGET_SIZES[WidgetType.IFRAME]!.minimum; + + expect(result['tab-1'].desktop[0]).toMatchObject({ + i: 'iframe-widget', + minW: iframeMinSize.w, + minH: iframeMinSize.h, + }); + }); }); diff --git a/packages/twenty-front/src/modules/page-layout/utils/__tests__/isReactivatableTab.test.ts b/packages/twenty-front/src/modules/page-layout/utils/__tests__/isReactivatableTab.test.ts index 3e339a772f..3c39d290e4 100644 --- a/packages/twenty-front/src/modules/page-layout/utils/__tests__/isReactivatableTab.test.ts +++ b/packages/twenty-front/src/modules/page-layout/utils/__tests__/isReactivatableTab.test.ts @@ -1,30 +1,15 @@ -import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab'; +import { makeTab } from '@/page-layout/testing/pageLayoutDraftFixtures'; import { isReactivatableTab } from '@/page-layout/utils/isReactivatableTab'; -const makeTab = (overrides: Partial = {}): PageLayoutTab => - ({ - id: 'tab-1', - applicationId: 'app-1', - title: 'Tab', - isActive: true, - position: 0, - pageLayoutId: '', - widgets: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - deletedAt: null, - ...overrides, - }) as unknown as PageLayoutTab; - describe('isReactivatableTab', () => { it('should return true when tab is inactive', () => { - const tab = makeTab({ isActive: false }); + const tab = makeTab('tab-1', [], 0, undefined, { isActive: false }); expect(isReactivatableTab(tab)).toBe(true); }); it('should return false when tab is active', () => { - const tab = makeTab({ isActive: true }); + const tab = makeTab('tab-1', [], 0, undefined, { isActive: true }); expect(isReactivatableTab(tab)).toBe(false); }); diff --git a/packages/twenty-front/src/modules/page-layout/utils/__tests__/moveWidgetToGridTabInDraft.test.ts b/packages/twenty-front/src/modules/page-layout/utils/__tests__/moveWidgetToGridTabInDraft.test.ts new file mode 100644 index 0000000000..056285c3c9 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/__tests__/moveWidgetToGridTabInDraft.test.ts @@ -0,0 +1,139 @@ +import { + makeDraft, + makeTab, + makeWidget, +} from '@/page-layout/testing/pageLayoutDraftFixtures'; +import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { moveWidgetToGridTabInDraft } from '@/page-layout/utils/moveWidgetToGridTabInDraft'; +import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql'; + +const makeGridWidget = ( + id: string, + tabId: string, + gridPosition: { + row: number; + column: number; + rowSpan: number; + columnSpan: number; + }, +): PageLayoutWidget => ({ + ...makeWidget(id, 0, tabId), + gridPosition, + position: { + __typename: 'PageLayoutWidgetGridPosition' as const, + layoutMode: PageLayoutTabLayoutMode.GRID, + ...gridPosition, + }, +}); + +const makeGridTab = (id: string, widgets: PageLayoutWidget[], position = 0) => + makeTab(id, widgets, position, PageLayoutTabLayoutMode.GRID); + +describe('moveWidgetToGridTabInDraft', () => { + it('moves the widget below the lowest widget of the destination grid', () => { + const draft = makeDraft([ + makeGridTab('tab-1', [ + makeGridWidget('widget-a', 'tab-1', { + row: 0, + column: 0, + rowSpan: 4, + columnSpan: 6, + }), + ]), + makeGridTab( + 'tab-2', + [ + makeGridWidget('widget-b', 'tab-2', { + row: 2, + column: 3, + rowSpan: 5, + columnSpan: 4, + }), + ], + 1, + ), + ]); + + const result = moveWidgetToGridTabInDraft(draft, { + widgetId: 'widget-a', + destinationTabId: 'tab-2', + }); + + expect(result.tabs[0].widgets).toHaveLength(0); + expect(result.tabs[1].widgets.map((widget) => widget.id)).toEqual([ + 'widget-b', + 'widget-a', + ]); + + const movedWidget = result.tabs[1].widgets.find( + (widget) => widget.id === 'widget-a', + ); + expect(movedWidget?.pageLayoutTabId).toBe('tab-2'); + expect(movedWidget?.gridPosition).toEqual({ + row: 7, + column: 0, + rowSpan: 4, + columnSpan: 6, + }); + }); + + it('places the widget at the top of an empty destination grid', () => { + const draft = makeDraft([ + makeGridTab('tab-1', [ + makeGridWidget('widget-a', 'tab-1', { + row: 3, + column: 2, + rowSpan: 2, + columnSpan: 2, + }), + ]), + makeGridTab('tab-2', [], 1), + ]); + + const result = moveWidgetToGridTabInDraft(draft, { + widgetId: 'widget-a', + destinationTabId: 'tab-2', + }); + + const movedWidget = result.tabs[1].widgets[0]; + expect(movedWidget?.gridPosition).toEqual({ + row: 0, + column: 0, + rowSpan: 2, + columnSpan: 2, + }); + }); + + it('rejects vertical-list destinations and no-op moves', () => { + const draft = makeDraft([ + makeGridTab('tab-1', [ + makeGridWidget('widget-a', 'tab-1', { + row: 0, + column: 0, + rowSpan: 2, + columnSpan: 2, + }), + ]), + makeTab('tab-vertical', [], 1), + ]); + + expect( + moveWidgetToGridTabInDraft(draft, { + widgetId: 'widget-a', + destinationTabId: 'tab-vertical', + }), + ).toBe(draft); + expect( + moveWidgetToGridTabInDraft(draft, { + widgetId: 'widget-a', + destinationTabId: 'tab-1', + }), + ).toBe(draft); + expect( + moveWidgetToGridTabInDraft(draft, { + widgetId: 'missing', + destinationTabId: 'tab-1', + }), + ).toBe(draft); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/utils/__tests__/reorderTabInDraft.test.ts b/packages/twenty-front/src/modules/page-layout/utils/__tests__/reorderTabInDraft.test.ts new file mode 100644 index 0000000000..0de2eb2c05 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/__tests__/reorderTabInDraft.test.ts @@ -0,0 +1,109 @@ +import { + makeDraft, + makeTab, +} from '@/page-layout/testing/pageLayoutDraftFixtures'; +import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout'; +import { reorderTabInDraft } from '@/page-layout/utils/reorderTabInDraft'; + +const orderOf = (draft: DraftPageLayout) => + [...draft.tabs] + .sort((tabA, tabB) => tabA.position - tabB.position) + .map((tab) => tab.id); + +describe('reorderTabInDraft', () => { + it('moves a tab before another tab', () => { + const draft = makeDraft([ + makeTab('tab-a', [], 0), + makeTab('tab-b', [], 1), + makeTab('tab-c', [], 2), + ]); + + const result = reorderTabInDraft(draft, { + tabId: 'tab-c', + beforeTabId: 'tab-a', + }); + + expect(orderOf(result)).toEqual(['tab-c', 'tab-a', 'tab-b']); + }); + + it('moves a tab forward before a later tab, compensating for its removal', () => { + const draft = makeDraft([ + makeTab('tab-a', [], 0), + makeTab('tab-b', [], 1), + makeTab('tab-c', [], 2), + ]); + + const result = reorderTabInDraft(draft, { + tabId: 'tab-a', + beforeTabId: 'tab-c', + }); + + expect(orderOf(result)).toEqual(['tab-b', 'tab-a', 'tab-c']); + }); + + it('appends the tab at the end when beforeTabId is null', () => { + const draft = makeDraft([ + makeTab('tab-a', [], 0), + makeTab('tab-b', [], 1), + makeTab('tab-c', [], 2), + ]); + + const result = reorderTabInDraft(draft, { + tabId: 'tab-a', + beforeTabId: null, + }); + + expect(orderOf(result)).toEqual(['tab-b', 'tab-c', 'tab-a']); + }); + + it('keeps tabs that are not rendered in the tab list in place', () => { + const draft = makeDraft([ + makeTab('pinned-tab', [], 0), + makeTab('tab-a', [], 1), + makeTab('tab-b', [], 2), + ]); + + const result = reorderTabInDraft(draft, { + tabId: 'tab-b', + beforeTabId: 'tab-a', + }); + + expect(orderOf(result)).toEqual(['pinned-tab', 'tab-b', 'tab-a']); + }); + + it('ignores inactive tabs when computing positions', () => { + const draft = makeDraft([ + makeTab('tab-a', [], 0), + makeTab('tab-inactive', [], 1, undefined, { isActive: false }), + makeTab('tab-b', [], 2), + ]); + + const result = reorderTabInDraft(draft, { + tabId: 'tab-b', + beforeTabId: 'tab-a', + }); + + expect(result.tabs.find((tab) => tab.id === 'tab-inactive')?.position).toBe( + 1, + ); + expect(result.tabs.find((tab) => tab.id === 'tab-b')?.position).toBe(0); + expect(result.tabs.find((tab) => tab.id === 'tab-a')?.position).toBe(1); + }); + + it('returns the draft unchanged when the move is a no-op', () => { + const draft = makeDraft([makeTab('tab-a', [], 0), makeTab('tab-b', [], 1)]); + + expect( + reorderTabInDraft(draft, { tabId: 'tab-a', beforeTabId: 'tab-b' }), + ).toBe(draft); + expect( + reorderTabInDraft(draft, { tabId: 'tab-a', beforeTabId: 'tab-a' }), + ).toBe(draft); + expect( + reorderTabInDraft(draft, { tabId: 'missing', beforeTabId: null }), + ).toBe(draft); + expect( + reorderTabInDraft(draft, { tabId: 'tab-a', beforeTabId: 'missing' }), + ).toBe(draft); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/utils/__tests__/sortWidgetsByVerticalListPosition.test.ts b/packages/twenty-front/src/modules/page-layout/utils/__tests__/sortWidgetsByVerticalListPosition.test.ts index cec7559113..84afc83605 100644 --- a/packages/twenty-front/src/modules/page-layout/utils/__tests__/sortWidgetsByVerticalListPosition.test.ts +++ b/packages/twenty-front/src/modules/page-layout/utils/__tests__/sortWidgetsByVerticalListPosition.test.ts @@ -1,33 +1,6 @@ +import { makeWidget } from '@/page-layout/testing/pageLayoutDraftFixtures'; import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; import { sortWidgetsByVerticalListPosition } from '@/page-layout/utils/sortWidgetsByVerticalListPosition'; -import { - PageLayoutTabLayoutMode, - WidgetConfigurationType, - WidgetType, -} from '~/generated-metadata/graphql'; - -const makeWidget = (id: string, index: number): PageLayoutWidget => - ({ - __typename: 'PageLayoutWidget', - id, - pageLayoutTabId: 'tab-1', - title: id, - type: WidgetType.FIELDS, - gridPosition: { row: 0, column: 0, rowSpan: 1, columnSpan: 1 }, - configuration: { - __typename: 'FieldsConfiguration', - configurationType: WidgetConfigurationType.FIELDS, - }, - position: { - __typename: 'PageLayoutWidgetVerticalListPosition', - layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, - index, - }, - objectMetadataId: null, - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - deletedAt: null, - }) as unknown as PageLayoutWidget; describe('sortWidgetsByVerticalListPosition', () => { it('should sort widgets by index ascending', () => { diff --git a/packages/twenty-front/src/modules/page-layout/utils/buildTabWidgetLayouts.ts b/packages/twenty-front/src/modules/page-layout/utils/buildTabWidgetLayouts.ts new file mode 100644 index 0000000000..7d0cabfaa4 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/buildTabWidgetLayouts.ts @@ -0,0 +1,47 @@ +import { type Layouts } from 'react-grid-layout'; +import { DEFAULT_WIDGET_SIZE } from 'twenty-shared/constants'; +import { isDefined } from 'twenty-shared/utils'; + +import { WIDGET_SIZES } from '@/page-layout/constants/WidgetSizes'; +import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { getWidgetGridPosition } from '@/page-layout/utils/getWidgetGridPosition'; +import { getWidgetSize } from '@/page-layout/utils/getWidgetSize'; + +const getWidgetMinimumSize = (widget: PageLayoutWidget) => { + const typeMinimum = WIDGET_SIZES[widget.type]?.minimum; + + if (isDefined(typeMinimum)) { + return typeMinimum; + } + + if ( + isDefined(widget.configuration) && + widget.configuration.__typename !== 'FieldsConfiguration' + ) { + return getWidgetSize(widget.configuration.configurationType, 'minimum'); + } + + return DEFAULT_WIDGET_SIZE.minimum; +}; + +export const buildTabWidgetLayouts = (widgets: PageLayoutWidget[]): Layouts => { + const layouts = widgets.map((widget) => { + const minimumSize = getWidgetMinimumSize(widget); + const gridPos = getWidgetGridPosition(widget); + + return { + i: widget.id, + x: gridPos?.column ?? 0, + y: gridPos?.row ?? 0, + w: gridPos?.columnSpan ?? DEFAULT_WIDGET_SIZE.default.w, + h: gridPos?.rowSpan ?? DEFAULT_WIDGET_SIZE.default.h, + minW: minimumSize.w, + minH: minimumSize.h, + }; + }); + + return { + desktop: layouts, + mobile: layouts.map((layout) => ({ ...layout, w: 1, x: 0 })), + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/utils/convertPageLayoutToTabLayouts.ts b/packages/twenty-front/src/modules/page-layout/utils/convertPageLayoutToTabLayouts.ts index abd95484b0..51e117681e 100644 --- a/packages/twenty-front/src/modules/page-layout/utils/convertPageLayoutToTabLayouts.ts +++ b/packages/twenty-front/src/modules/page-layout/utils/convertPageLayoutToTabLayouts.ts @@ -1,8 +1,6 @@ -import { DEFAULT_WIDGET_SIZE } from 'twenty-shared/constants'; import { type PageLayout } from '@/page-layout/types/PageLayout'; import { type TabLayouts } from '@/page-layout/types/TabLayouts'; -import { getWidgetSize } from '@/page-layout/utils/getWidgetSize'; -import { isDefined } from 'twenty-shared/utils'; +import { buildTabWidgetLayouts } from '@/page-layout/utils/buildTabWidgetLayouts'; export const convertPageLayoutToTabLayouts = ( pageLayout: PageLayout, @@ -14,50 +12,7 @@ export const convertPageLayoutToTabLayouts = ( const tabLayouts: TabLayouts = {}; pageLayout.tabs.forEach((tab) => { - const layouts = tab.widgets.map((widget) => { - let minW = DEFAULT_WIDGET_SIZE.minimum.w; - let minH = DEFAULT_WIDGET_SIZE.minimum.h; - - if (isDefined(widget.configuration)) { - if (widget.configuration.__typename === 'FieldsConfiguration') { - minW = DEFAULT_WIDGET_SIZE.minimum.w; - minH = DEFAULT_WIDGET_SIZE.minimum.h; - } else { - const minimumSize = getWidgetSize( - widget.configuration.configurationType, - 'minimum', - ); - minW = minimumSize.w; - minH = minimumSize.h; - } - } - - const gridPos = - isDefined(widget.position) && - widget.position.__typename === 'PageLayoutWidgetGridPosition' - ? { - row: widget.position.row, - column: widget.position.column, - rowSpan: widget.position.rowSpan, - columnSpan: widget.position.columnSpan, - } - : widget.gridPosition; - - return { - i: widget.id, - x: gridPos?.column ?? 0, - y: gridPos?.row ?? 0, - w: gridPos?.columnSpan ?? DEFAULT_WIDGET_SIZE.default.w, - h: gridPos?.rowSpan ?? DEFAULT_WIDGET_SIZE.default.h, - minW, - minH, - }; - }); - - tabLayouts[tab.id] = { - desktop: layouts, - mobile: layouts.map((layout) => ({ ...layout, w: 1, x: 0 })), - }; + tabLayouts[tab.id] = buildTabWidgetLayouts(tab.widgets); }); return tabLayouts; diff --git a/packages/twenty-front/src/modules/page-layout/utils/getWidgetGridPosition.ts b/packages/twenty-front/src/modules/page-layout/utils/getWidgetGridPosition.ts new file mode 100644 index 0000000000..198d17233f --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/getWidgetGridPosition.ts @@ -0,0 +1,11 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; + +// Widgets carry their grid position either as the GraphQL union or as the +// plain gridPosition field depending on where they came from; the union wins. +export const getWidgetGridPosition = (widget: PageLayoutWidget) => + isDefined(widget.position) && + widget.position.__typename === 'PageLayoutWidgetGridPosition' + ? widget.position + : widget.gridPosition; diff --git a/packages/twenty-front/src/modules/page-layout/utils/moveWidgetToGridTabInDraft.ts b/packages/twenty-front/src/modules/page-layout/utils/moveWidgetToGridTabInDraft.ts new file mode 100644 index 0000000000..5d470d9f7a --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/moveWidgetToGridTabInDraft.ts @@ -0,0 +1,94 @@ +import { isDefined } from 'twenty-shared/utils'; +import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql'; + +import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout'; +import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { getWidgetGridPosition } from '@/page-layout/utils/getWidgetGridPosition'; + +type MoveWidgetToGridTabInDraftParams = { + widgetId: string; + destinationTabId: string; +}; + +// Moves a widget into a grid tab, placing it full-left below the lowest +// existing widget so it never overlaps the destination layout. +export const moveWidgetToGridTabInDraft = ( + draft: DraftPageLayout, + { widgetId, destinationTabId }: MoveWidgetToGridTabInDraftParams, +): DraftPageLayout => { + const sourceTab = draft.tabs.find((tab) => + tab.widgets.some((widget) => widget.id === widgetId), + ); + + if (!isDefined(sourceTab) || sourceTab.id === destinationTabId) { + return draft; + } + + const destinationTab = draft.tabs.find((tab) => tab.id === destinationTabId); + + if ( + !isDefined(destinationTab) || + destinationTab.layoutMode === PageLayoutTabLayoutMode.VERTICAL_LIST + ) { + return draft; + } + + const widget = sourceTab.widgets.find( + (candidateWidget) => candidateWidget.id === widgetId, + ); + + if (!isDefined(widget)) { + return draft; + } + + const destinationBottomRow = destinationTab.widgets.reduce( + (bottomRow, destinationWidget) => { + const gridPosition = getWidgetGridPosition(destinationWidget); + + return Math.max( + bottomRow, + (gridPosition?.row ?? 0) + (gridPosition?.rowSpan ?? 0), + ); + }, + 0, + ); + + const widgetGridPosition = getWidgetGridPosition(widget); + const rowSpan = widgetGridPosition?.rowSpan ?? 2; + const columnSpan = widgetGridPosition?.columnSpan ?? 2; + + const movedWidget: PageLayoutWidget = { + ...widget, + pageLayoutTabId: destinationTabId, + gridPosition: { + row: destinationBottomRow, + column: 0, + rowSpan, + columnSpan, + }, + position: { + __typename: 'PageLayoutWidgetGridPosition' as const, + layoutMode: PageLayoutTabLayoutMode.GRID, + row: destinationBottomRow, + column: 0, + rowSpan, + columnSpan, + }, + }; + + return { + ...draft, + tabs: draft.tabs.map((tab) => { + if (tab.id === sourceTab.id) { + return { + ...tab, + widgets: tab.widgets.filter((tabWidget) => tabWidget.id !== widgetId), + }; + } + if (tab.id === destinationTabId) { + return { ...tab, widgets: [...tab.widgets, movedWidget] }; + } + return tab; + }), + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/utils/reorderTabInDraft.ts b/packages/twenty-front/src/modules/page-layout/utils/reorderTabInDraft.ts new file mode 100644 index 0000000000..7c87e5d657 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/reorderTabInDraft.ts @@ -0,0 +1,54 @@ +import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout'; +import { sortTabsByPosition } from '@/page-layout/utils/sortTabsByPosition'; +import { isDefined } from 'twenty-shared/utils'; + +type ReorderTabInDraftParams = { + tabId: string; + beforeTabId: string | null; +}; + +// Repositions a tab relative to another one; beforeTabId null appends it after +// the last active tab. Operating on ids instead of list indices keeps tabs not +// rendered in the tab list (the pinned first tab) in place without index +// arithmetic. +export const reorderTabInDraft = ( + draft: DraftPageLayout, + { tabId, beforeTabId }: ReorderTabInDraftParams, +): DraftPageLayout => { + const orderedIds = sortTabsByPosition( + draft.tabs.filter((tab) => tab.isActive), + ).map((tab) => tab.id); + + if (!orderedIds.includes(tabId)) { + return draft; + } + + const reorderedIds = orderedIds.filter( + (candidateTabId) => candidateTabId !== tabId, + ); + + const insertIndex = isDefined(beforeTabId) + ? reorderedIds.indexOf(beforeTabId) + : reorderedIds.length; + + if (insertIndex < 0) { + return draft; + } + + reorderedIds.splice(insertIndex, 0, tabId); + + if (reorderedIds.every((id, index) => id === orderedIds[index])) { + return draft; + } + + const newPositionById = new Map(reorderedIds.map((id, index) => [id, index])); + + return { + ...draft, + tabs: draft.tabs.map((tab) => { + const newPosition = newPositionById.get(tab.id); + + return isDefined(newPosition) ? { ...tab, position: newPosition } : tab; + }), + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationEditor.tsx b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationEditor.tsx index c70e073d66..0fea7d5ef0 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationEditor.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationEditor.tsx @@ -1,9 +1,4 @@ -import { - DragDropContext, - Draggable, - Droppable, - type DropResult, -} from '@hello-pangea/dnd'; +import { DragDropProvider } from '@dnd-kit/react'; import { styled } from '@linaria/react'; import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow'; @@ -11,17 +6,25 @@ import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fiel import { fieldsWidgetUngroupedFieldsDraftComponentState } from '@/page-layout/states/fieldsWidgetUngroupedFieldsDraftComponentState'; import { FieldsConfigurationGroupEditor } from '@/page-layout/widgets/fields/components/FieldsConfigurationGroupEditor'; import { FieldsConfigurationUngroupedEditor } from '@/page-layout/widgets/fields/components/FieldsConfigurationUngroupedEditor'; +import { FIELDS_CONFIGURATION_GROUP_DND_TYPE } from '@/page-layout/widgets/fields/constants/FieldsConfigurationGroupDndType'; +import { FIELDS_CONFIGURATION_GROUPS_DROPPABLE_ID } from '@/page-layout/widgets/fields/constants/FieldsConfigurationGroupsDroppableId'; import { useCreateFieldsWidgetEditorGroup } from '@/page-layout/widgets/fields/hooks/useCreateFieldsWidgetEditorGroup'; import { useDeleteFieldsWidgetEditorGroup } from '@/page-layout/widgets/fields/hooks/useDeleteFieldsWidgetEditorGroup'; +import { useFieldsConfigurationEditorDragAndDrop } from '@/page-layout/widgets/fields/hooks/useFieldsConfigurationEditorDragAndDrop'; import { useFieldsWidgetEditorMode } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetEditorMode'; -import { useMoveFieldInDraft } from '@/page-layout/widgets/fields/hooks/useMoveFieldInDraft'; import { useMoveUngroupedFieldInDraft } from '@/page-layout/widgets/fields/hooks/useMoveUngroupedFieldInDraft'; -import { useReorderFieldsWidgetEditorGroups } from '@/page-layout/widgets/fields/hooks/useReorderFieldsWidgetEditorGroups'; import { useToggleFieldVisibilityInDraft } from '@/page-layout/widgets/fields/hooks/useToggleFieldVisibilityInDraft'; import { useToggleUngroupedFieldVisibilityInDraft } from '@/page-layout/widgets/fields/hooks/useToggleUngroupedFieldVisibilityInDraft'; import { useUpdateFieldsWidgetEditorGroup } from '@/page-layout/widgets/fields/hooks/useUpdateFieldsWidgetEditorGroup'; +import { type FieldsConfigurationDndData } from '@/page-layout/widgets/fields/types/FieldsConfigurationDndData'; +import { type FieldsConfigurationGroupDragData } from '@/page-layout/widgets/fields/types/FieldsConfigurationGroupDragData'; +import { type FieldsConfigurationGroupListEndDropData } from '@/page-layout/widgets/fields/types/FieldsConfigurationGroupListEndDropData'; import { getFieldsConfigurationGroupRenameDropdownId } from '@/page-layout/widgets/fields/utils/getFieldsConfigurationGroupRenameDropdownId'; import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown'; +import { DragDropItemEndDropZone } from '@/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone'; +import { DragDropItemSortableCell } from '@/ui/utilities/drag-and-drop/components/DragDropItemSortableCell'; +import { DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION } from '@/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation'; +import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { useLingui } from '@lingui/react/macro'; import { useState } from 'react'; @@ -38,6 +41,10 @@ const StyledAddGroupButtonContainer = styled.div` border: 1px solid transparent; `; +const GROUPS_END_DROP_DATA: FieldsConfigurationGroupListEndDropData = { + type: 'group-list-end', +}; + type FieldsConfigurationEditorProps = { pageLayoutId: string; widgetId: string; @@ -74,15 +81,12 @@ export const FieldsConfigurationEditor = ({ widgetId, }); - const { reorderGroups } = useReorderFieldsWidgetEditorGroups({ - pageLayoutId, - widgetId, - }); - - const { moveField } = useMoveFieldInDraft({ - pageLayoutId, - widgetId, - }); + const { draggingGroupId, handlers } = useFieldsConfigurationEditorDragAndDrop( + { + pageLayoutId, + widgetId, + }, + ); const { toggleFieldVisibility } = useToggleFieldVisibilityInDraft({ pageLayoutId, @@ -132,64 +136,6 @@ export const FieldsConfigurationEditor = ({ deleteGroup(groupId); }; - const handleDragEnd = (result: DropResult) => { - const { source, destination, type } = result; - - if (!destination) { - return; - } - - if ( - source.droppableId === destination.droppableId && - source.index === destination.index - ) { - return; - } - - if (type === 'GROUP') { - handleGroupReorder(source.index, destination.index); - } else if (type === 'FIELD') { - handleFieldMove( - source.droppableId, - destination.droppableId, - source.index, - destination.index, - ); - } - }; - - const handleGroupReorder = ( - sourceIndex: number, - destinationIndex: number, - ) => { - const sortedGroups = [...draftGroups].sort( - (a, b) => a.position - b.position, - ); - - const reorderedGroupIds = sortedGroups.map((g) => g.id); - const [movedGroupId] = reorderedGroupIds.splice(sourceIndex, 1); - reorderedGroupIds.splice(destinationIndex, 0, movedGroupId); - - reorderGroups(reorderedGroupIds); - }; - - const handleFieldMove = ( - sourceGroupId: string, - destinationGroupId: string, - sourceIndex: number, - destinationIndex: number, - ) => { - const cleanSourceGroupId = sourceGroupId.replace('group-', ''); - const cleanDestinationGroupId = destinationGroupId.replace('group-', ''); - - moveField( - cleanSourceGroupId, - cleanDestinationGroupId, - sourceIndex, - destinationIndex, - ); - }; - const handleAddGroup = ({ afterGroupId }: { afterGroupId?: string }) => { const newGroupName = t`New Group`; const newGroupId = createGroup({ name: newGroupName, afterGroupId }); @@ -219,56 +165,66 @@ export const FieldsConfigurationEditor = ({ } return ( - - - {(provided) => ( - - {sortedGroups.map((group, index) => ( - - {(draggableProvided, snapshot) => ( - - handleAddGroup({ afterGroupId: group.id }) - } - onToggleFieldVisibility={(fieldMetadataId) => - toggleFieldVisibility(group.id, fieldMetadataId) - } - onRenameGroup={handleRenameGroup} - onDeleteGroup={handleDeleteGroup} - renamingGroupValue={renamingGroupValue} - onRenamingGroupValueChange={setRenamingGroupValue} - onStartRename={handleStartRename} - /> - )} - - ))} - {provided.placeholder} + + sensors={DND_KIT_SENSORS} + plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} + onDragStart={handlers.onDragStart} + onDragEnd={handlers.onDragEnd} + > + + {sortedGroups.map((group, index) => { + const groupDragData: FieldsConfigurationGroupDragData = { + type: 'group', + groupId: group.id, + index, + }; - - handleAddGroup({})} - withIconContainer - withIconContainerBackground={false} + return ( + + handleAddGroup({ afterGroupId: group.id })} + onToggleFieldVisibility={(fieldMetadataId) => + toggleFieldVisibility(group.id, fieldMetadataId) + } + onRenameGroup={handleRenameGroup} + onDeleteGroup={handleDeleteGroup} + renamingGroupValue={renamingGroupValue} + onRenamingGroupValueChange={setRenamingGroupValue} + onStartRename={handleStartRename} /> - - - )} - - + + ); + })} + + + + handleAddGroup({})} + withIconContainer + withIconContainerBackground={false} + /> + + + + ); }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationGroupEditor.tsx b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationGroupEditor.tsx index 5348453d92..b6613fa4cc 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationGroupEditor.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationGroupEditor.tsx @@ -1,14 +1,13 @@ -import { Droppable, type DraggableProvided } from '@hello-pangea/dnd'; import { styled } from '@linaria/react'; import { useLingui } from '@lingui/react/macro'; -import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem'; -import { getCssCompatibleDraggableProps } from '@/ui/layout/draggable-list/utils/getCssCompatibleDraggableProps'; - import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem'; import { FieldsConfigurationFieldEditor } from '@/page-layout/widgets/fields/components/FieldsConfigurationFieldEditor'; import { FieldsConfigurationGroupDropdown } from '@/page-layout/widgets/fields/components/FieldsConfigurationGroupDropdown'; import { FieldsConfigurationGroupRenameInput } from '@/page-layout/widgets/fields/components/FieldsConfigurationGroupRenameInput'; +import { FIELDS_CONFIGURATION_FIELD_DND_TYPE } from '@/page-layout/widgets/fields/constants/FieldsConfigurationFieldDndType'; +import { type FieldsConfigurationFieldDragData } from '@/page-layout/widgets/fields/types/FieldsConfigurationFieldDragData'; +import { type FieldsConfigurationFieldListEndDropData } from '@/page-layout/widgets/fields/types/FieldsConfigurationFieldListEndDropData'; import { type FieldsWidgetGroup } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup'; import { getFieldsConfigurationGroupRenameDropdownId } from '@/page-layout/widgets/fields/utils/getFieldsConfigurationGroupRenameDropdownId'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; @@ -16,6 +15,9 @@ import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown'; +import { DragDropItemEndDropZone } from '@/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone'; +import { DragDropItemSortableCell } from '@/ui/utilities/drag-and-drop/components/DragDropItemSortableCell'; +import { DragDropItemSortableHandle } from '@/ui/utilities/drag-and-drop/components/DragDropItemSortableHandle'; import { FieldsConfigurationGroupDraggableHeader } from '@/page-layout/widgets/fields/components/FieldsConfigurationGroupDraggableHeader'; import { themeCssVariables } from 'twenty-ui/theme-constants'; @@ -25,7 +27,7 @@ const StyledFieldsDroppable = styled.div` flex-direction: column; `; -const StyledEmptyGroupDropZone = styled.div` +const StyledEmptyGroupDropZone = styled(DragDropItemEndDropZone)` align-items: center; border: 1px dashed ${themeCssVariables.border.color.medium}; border-radius: ${themeCssVariables.border.radius.sm}; @@ -37,6 +39,12 @@ const StyledEmptyGroupDropZone = styled.div` min-height: ${themeCssVariables.spacing[10]}; `; +// Kept tall enough that appending after the group's last field stays an easy +// target. +const StyledFieldsEndDropZone = styled(DragDropItemEndDropZone)` + min-height: ${themeCssVariables.spacing[4]}; +`; + const StyledGroupContainer = styled.div<{ isDragging: boolean }>` background: ${({ isDragging }) => isDragging ? themeCssVariables.background.primary : 'transparent'}; @@ -71,9 +79,7 @@ const StyledDropdownContainer = styled.div` type FieldsConfigurationGroupEditorProps = { group: FieldsWidgetGroup; - index: number; objectMetadataItem: EnrichedObjectMetadataItem; - draggableProvided: DraggableProvided; isDragging: boolean; onAddGroup?: () => void; onToggleFieldVisibility: (fieldMetadataId: string) => void; @@ -86,7 +92,6 @@ type FieldsConfigurationGroupEditorProps = { export const FieldsConfigurationGroupEditor = ({ group, - draggableProvided, isDragging, onAddGroup, onToggleFieldVisibility, @@ -127,45 +132,48 @@ export const FieldsConfigurationGroupEditor = ({ onRenameGroup({ groupId, newName }); }; + const fieldsEndDropData: FieldsConfigurationFieldListEndDropData = { + type: 'field-list-end', + groupId: group.id, + }; + const sortedFields = [...group.fields].sort( (a, b) => a.position - b.position, ); return ( - - {/* oxlint-disable-next-line react/jsx-props-no-spreading */} - - - - - } - disableClickForClickableComponent - dropdownPlacement="bottom-start" - dropdownOffset={{ x: 32 }} - onClose={handleCancelRename} - dropdownComponents={ - - - handleRenameGroup({ groupId: group.id, newName }) - } - onCancel={handleCancelRename} - /> - - } - /> + + + + + + + } + disableClickForClickableComponent + dropdownPlacement="bottom-start" + dropdownOffset={{ x: 32 }} + onClose={handleCancelRename} + dropdownComponents={ + + + handleRenameGroup({ groupId: group.id, newName }) + } + onCancel={handleCancelRename} + /> + + } + /> + - - {(droppableProvided) => ( - + {sortedFields.length === 0 ? ( + - {sortedFields.length === 0 && ( - - {t`Drop fields here`} - - )} + {t`Drop fields here`} + + ) : ( + <> {sortedFields.map((field, fieldIndex) => { + const fieldDragData: FieldsConfigurationFieldDragData = { + type: 'field', + groupId: group.id, + index: fieldIndex, + }; + return ( - { - onToggleFieldVisibility(field.fieldMetadataItem.id); - }} - /> - } - /> + group={group.id} + data={fieldDragData} + type={FIELDS_CONFIGURATION_FIELD_DND_TYPE} + accept={FIELDS_CONFIGURATION_FIELD_DND_TYPE} + hasTransition={false} + highlightWhileDragging + dropLine="horizontal" + > + { + onToggleFieldVisibility(field.fieldMetadataItem.id); + }} + /> + ); })} - {droppableProvided.placeholder} - + + )} - + ); }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationUngroupedEditor.tsx b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationUngroupedEditor.tsx index 6f1bc4299f..d4b73b5253 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationUngroupedEditor.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationUngroupedEditor.tsx @@ -1,20 +1,36 @@ -import { DragDropContext, Droppable, type DropResult } from '@hello-pangea/dnd'; - -import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem'; +import { DragDropProvider } from '@dnd-kit/react'; +import { isDefined } from 'twenty-shared/utils'; import { FieldsConfigurationFieldEditor } from '@/page-layout/widgets/fields/components/FieldsConfigurationFieldEditor'; +import { FIELDS_CONFIGURATION_FIELD_DND_TYPE } from '@/page-layout/widgets/fields/constants/FieldsConfigurationFieldDndType'; +import { type FieldsConfigurationDndData } from '@/page-layout/widgets/fields/types/FieldsConfigurationDndData'; +import { type FieldsConfigurationFieldDragData } from '@/page-layout/widgets/fields/types/FieldsConfigurationFieldDragData'; +import { type FieldsConfigurationFieldListEndDropData } from '@/page-layout/widgets/fields/types/FieldsConfigurationFieldListEndDropData'; import { type FieldsWidgetGroupField } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup'; +import { DragDropItemEndDropZone } from '@/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone'; +import { DragDropItemSortableCell } from '@/ui/utilities/drag-and-drop/components/DragDropItemSortableCell'; +import { DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION } from '@/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation'; +import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors'; +import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; +import { getDestinationIndex } from '@/ui/utilities/drag-and-drop/utils/getDestinationIndex'; import { styled } from '@linaria/react'; import { useLingui } from '@lingui/react/macro'; import { IconNewSection } from 'twenty-ui/icon'; import { MenuItem } from 'twenty-ui/navigation'; +const UNGROUPED_FIELDS_DROPPABLE_ID = 'ungrouped-fields'; + const StyledFieldsDroppable = styled.div` display: flex; flex-direction: column; width: 100%; `; +const UNGROUPED_END_DROP_DATA: FieldsConfigurationFieldListEndDropData = { + type: 'field-list-end', + groupId: UNGROUPED_FIELDS_DROPPABLE_ID, +}; + type FieldsConfigurationUngroupedEditorProps = { ungroupedFields: FieldsWidgetGroupField[]; onMoveField: (sourceIndex: number, destinationIndex: number) => void; @@ -34,62 +50,103 @@ export const FieldsConfigurationUngroupedEditor = ({ (a, b) => a.position - b.position, ); - const handleDragEnd = (result: DropResult) => { - const { source, destination } = result; + const handleDragEnd = ( + event: DragDropProviderDragEndEvent, + ) => { + const sourceData = event.operation.source?.data as + | FieldsConfigurationDndData + | undefined; + const targetData = event.operation.target?.data as + | FieldsConfigurationDndData + | undefined; - if (!destination) { + if (event.canceled || sourceData?.type !== 'field') { return; } - if (source.index === destination.index) { + // The drop line renders before the hovered field, so field targets insert + // the dragged field before them; the end drop zone appends it. + const dropTargetIndex = + targetData?.type === 'field' + ? targetData.index + : targetData?.type === 'field-list-end' + ? sortedFields.length + : null; + + if (!isDefined(dropTargetIndex)) { return; } - onMoveField(source.index, destination.index); + const destinationIndex = getDestinationIndex({ + dropTargetIndex, + sourceIndex: sourceData.index, + sourceDroppableId: UNGROUPED_FIELDS_DROPPABLE_ID, + destinationDroppableId: UNGROUPED_FIELDS_DROPPABLE_ID, + }); + + if (destinationIndex === sourceData.index) { + return; + } + + onMoveField(sourceData.index, destinationIndex); }; return ( - - - {(provided) => ( - - {sortedFields.map((field, fieldIndex) => ( - { - onToggleFieldVisibility(field.fieldMetadataItem.id); - }} - /> - } - /> - ))} - {provided.placeholder} + + sensors={DND_KIT_SENSORS} + plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} + onDragEnd={handleDragEnd} + > + + {sortedFields.map((field, fieldIndex) => { + const fieldDragData: FieldsConfigurationFieldDragData = { + type: 'field', + groupId: UNGROUPED_FIELDS_DROPPABLE_ID, + index: fieldIndex, + }; - - - )} - - + return ( + + { + onToggleFieldVisibility(field.fieldMetadataItem.id); + }} + /> + + ); + })} + + + + + + ); }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/constants/FieldsConfigurationFieldDndType.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/constants/FieldsConfigurationFieldDndType.ts new file mode 100644 index 0000000000..67ce8ab6b1 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/constants/FieldsConfigurationFieldDndType.ts @@ -0,0 +1 @@ +export const FIELDS_CONFIGURATION_FIELD_DND_TYPE = 'fields-config-field'; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/constants/FieldsConfigurationGroupDndType.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/constants/FieldsConfigurationGroupDndType.ts new file mode 100644 index 0000000000..bdeb6c6f4b --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/constants/FieldsConfigurationGroupDndType.ts @@ -0,0 +1 @@ +export const FIELDS_CONFIGURATION_GROUP_DND_TYPE = 'fields-config-group'; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/constants/FieldsConfigurationGroupsDroppableId.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/constants/FieldsConfigurationGroupsDroppableId.ts new file mode 100644 index 0000000000..ab494ccb1d --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/constants/FieldsConfigurationGroupsDroppableId.ts @@ -0,0 +1,2 @@ +export const FIELDS_CONFIGURATION_GROUPS_DROPPABLE_ID = + 'fields-configuration-groups'; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsConfigurationEditorDragAndDrop.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsConfigurationEditorDragAndDrop.ts new file mode 100644 index 0000000000..7df29753e3 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsConfigurationEditorDragAndDrop.ts @@ -0,0 +1,173 @@ +import { useState } from 'react'; +import { isDefined } from 'twenty-shared/utils'; + +import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState'; +import { FIELDS_CONFIGURATION_GROUPS_DROPPABLE_ID } from '@/page-layout/widgets/fields/constants/FieldsConfigurationGroupsDroppableId'; +import { useMoveFieldInDraft } from '@/page-layout/widgets/fields/hooks/useMoveFieldInDraft'; +import { useReorderFieldsWidgetEditorGroups } from '@/page-layout/widgets/fields/hooks/useReorderFieldsWidgetEditorGroups'; +import { type FieldsConfigurationDndData } from '@/page-layout/widgets/fields/types/FieldsConfigurationDndData'; +import { type FieldsConfigurationFieldDragData } from '@/page-layout/widgets/fields/types/FieldsConfigurationFieldDragData'; +import { type FieldsConfigurationGroupDragData } from '@/page-layout/widgets/fields/types/FieldsConfigurationGroupDragData'; +import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; +import { type DragDropProviderDragStartEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragStartEvent'; +import { getDestinationIndex } from '@/ui/utilities/drag-and-drop/utils/getDestinationIndex'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; + +type DragStartEvent = + DragDropProviderDragStartEvent; +type DragEndEvent = DragDropProviderDragEndEvent; + +type UseFieldsConfigurationEditorDragAndDropParams = { + pageLayoutId: string; + widgetId: string; +}; + +export const useFieldsConfigurationEditorDragAndDrop = ({ + pageLayoutId, + widgetId, +}: UseFieldsConfigurationEditorDragAndDropParams) => { + const fieldsWidgetGroupsDraft = useAtomComponentStateValue( + fieldsWidgetGroupsDraftComponentState, + pageLayoutId, + ); + + const draftGroups = fieldsWidgetGroupsDraft[widgetId] ?? []; + + const { reorderGroups } = useReorderFieldsWidgetEditorGroups({ + pageLayoutId, + widgetId, + }); + + const { moveField } = useMoveFieldInDraft({ + pageLayoutId, + widgetId, + }); + + const [draggingGroupId, setDraggingGroupId] = useState(null); + + const handleGroupDrop = ({ + sourceData, + targetData, + }: { + sourceData: FieldsConfigurationGroupDragData; + targetData: FieldsConfigurationDndData; + }) => { + const sortedGroups = [...draftGroups].sort( + (a, b) => a.position - b.position, + ); + + // The drop line renders before the hovered group, so group targets insert + // the dragged group before them; the end drop zone appends it. + const dropTargetIndex = + targetData.type === 'group' + ? targetData.index + : targetData.type === 'group-list-end' + ? sortedGroups.length + : null; + + if (!isDefined(dropTargetIndex)) { + return; + } + + const destinationIndex = getDestinationIndex({ + dropTargetIndex, + sourceIndex: sourceData.index, + sourceDroppableId: FIELDS_CONFIGURATION_GROUPS_DROPPABLE_ID, + destinationDroppableId: FIELDS_CONFIGURATION_GROUPS_DROPPABLE_ID, + }); + + if (destinationIndex === sourceData.index) { + return; + } + + const reorderedGroupIds = sortedGroups.map((group) => group.id); + const [movedGroupId] = reorderedGroupIds.splice(sourceData.index, 1); + reorderedGroupIds.splice(destinationIndex, 0, movedGroupId); + + reorderGroups(reorderedGroupIds); + }; + + const handleFieldDrop = ({ + sourceData, + targetData, + }: { + sourceData: FieldsConfigurationFieldDragData; + targetData: FieldsConfigurationDndData; + }) => { + if (targetData.type !== 'field' && targetData.type !== 'field-list-end') { + return; + } + + const destinationGroup = draftGroups.find( + (group) => group.id === targetData.groupId, + ); + + if (!isDefined(destinationGroup)) { + return; + } + + // The drop line renders before the hovered field, so field targets insert + // the dragged field before them; the end drop zone appends it to the group. + const dropTargetIndex = + targetData.type === 'field' + ? targetData.index + : destinationGroup.fields.length; + + const destinationIndex = getDestinationIndex({ + dropTargetIndex, + sourceIndex: sourceData.index, + sourceDroppableId: sourceData.groupId, + destinationDroppableId: targetData.groupId, + }); + + if ( + targetData.groupId === sourceData.groupId && + destinationIndex === sourceData.index + ) { + return; + } + + moveField( + sourceData.groupId, + targetData.groupId, + sourceData.index, + destinationIndex, + ); + }; + + const onDragStart = (event: DragStartEvent) => { + const sourceData = event.operation.source?.data as + | FieldsConfigurationDndData + | undefined; + + if (sourceData?.type === 'group') { + setDraggingGroupId(sourceData.groupId); + } + }; + + const onDragEnd = (event: DragEndEvent) => { + setDraggingGroupId(null); + + const sourceData = event.operation.source?.data as + | FieldsConfigurationDndData + | undefined; + const targetData = event.operation.target?.data as + | FieldsConfigurationDndData + | undefined; + + if (event.canceled || !isDefined(sourceData) || !isDefined(targetData)) { + return; + } + + if (sourceData.type === 'group') { + handleGroupDrop({ sourceData, targetData }); + } else if (sourceData.type === 'field') { + handleFieldDrop({ sourceData, targetData }); + } + }; + + return { + draggingGroupId, + handlers: { onDragStart, onDragEnd }, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationDndData.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationDndData.ts new file mode 100644 index 0000000000..430d769004 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationDndData.ts @@ -0,0 +1,10 @@ +import { type FieldsConfigurationFieldDragData } from '@/page-layout/widgets/fields/types/FieldsConfigurationFieldDragData'; +import { type FieldsConfigurationFieldListEndDropData } from '@/page-layout/widgets/fields/types/FieldsConfigurationFieldListEndDropData'; +import { type FieldsConfigurationGroupDragData } from '@/page-layout/widgets/fields/types/FieldsConfigurationGroupDragData'; +import { type FieldsConfigurationGroupListEndDropData } from '@/page-layout/widgets/fields/types/FieldsConfigurationGroupListEndDropData'; + +export type FieldsConfigurationDndData = + | FieldsConfigurationGroupDragData + | FieldsConfigurationGroupListEndDropData + | FieldsConfigurationFieldDragData + | FieldsConfigurationFieldListEndDropData; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationFieldDragData.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationFieldDragData.ts new file mode 100644 index 0000000000..79786326b5 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationFieldDragData.ts @@ -0,0 +1,5 @@ +export type FieldsConfigurationFieldDragData = { + type: 'field'; + groupId: string; + index: number; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationFieldListEndDropData.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationFieldListEndDropData.ts new file mode 100644 index 0000000000..f6cfd007bc --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationFieldListEndDropData.ts @@ -0,0 +1,5 @@ +// Catches drops below the last field of a list and drops into an empty group. +export type FieldsConfigurationFieldListEndDropData = { + type: 'field-list-end'; + groupId: string; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationGroupDragData.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationGroupDragData.ts new file mode 100644 index 0000000000..3a8ad18613 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationGroupDragData.ts @@ -0,0 +1,5 @@ +export type FieldsConfigurationGroupDragData = { + type: 'group'; + groupId: string; + index: number; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationGroupListEndDropData.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationGroupListEndDropData.ts new file mode 100644 index 0000000000..1dd5974380 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationGroupListEndDropData.ts @@ -0,0 +1,4 @@ +// Catches drops below the last group to append the dragged group at the end. +export type FieldsConfigurationGroupListEndDropData = { + type: 'group-list-end'; +}; diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectForm.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectForm.tsx index 556238fb18..78a947497a 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectForm.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectForm.tsx @@ -1,5 +1,5 @@ import { styled } from '@linaria/react'; -import { type DropResult } from '@hello-pangea/dnd'; +import { type DraggableListDropResult } from '@/ui/layout/draggable-list/types/DraggableListDropResult'; import { Controller, useFormContext } from 'react-hook-form'; import { z } from 'zod'; @@ -210,7 +210,7 @@ export const SettingsDataModelFieldSelectForm = ({ const handleDragEnd = ( values: FieldMetadataItemOption[], - result: DropResult, + result: DraggableListDropResult, onChange: (options: FieldMetadataItemOption[]) => void, ) => { if (!result.destination) return; @@ -440,7 +440,6 @@ export const SettingsDataModelFieldSelectForm = ({ <> {options.map((option, index) => ( ( - - {(provided) => ( -
- {children} -
- )} -
-); diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartManualSortSubMenuContent.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartManualSortSubMenuContent.tsx index fb8d9b3568..dd1f87067d 100644 --- a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartManualSortSubMenuContent.tsx +++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartManualSortSubMenuContent.tsx @@ -1,4 +1,4 @@ -import { type DropResult } from '@hello-pangea/dnd'; +import { type DraggableListDropResult } from '@/ui/layout/draggable-list/types/DraggableListDropResult'; import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdFromContextStore'; import { useUpdateCurrentWidgetConfig } from '@/side-panel/pages/page-layout/hooks/useUpdateCurrentWidgetConfig'; @@ -51,7 +51,7 @@ export const ChartManualSortSubMenuContent = ({ currentManualSortOrder, ); - const handleDragEnd = (result: DropResult) => { + const handleDragEnd = (result: DraggableListDropResult) => { if (!isDefined(result.destination)) { return; } diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/RecordTableFieldsDropdownVisibleFieldsContent.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/RecordTableFieldsDropdownVisibleFieldsContent.tsx index 075608ad01..32c2d8a07e 100644 --- a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/RecordTableFieldsDropdownVisibleFieldsContent.tsx +++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/RecordTableFieldsDropdownVisibleFieldsContent.tsx @@ -10,7 +10,7 @@ import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; -import { type DropResult } from '@hello-pangea/dnd'; +import { type DraggableListDropResult } from '@/ui/layout/draggable-list/types/DraggableListDropResult'; import { t } from '@lingui/core/macro'; import { isDefined } from 'twenty-shared/utils'; import { IconEyeOff, useIcons } from 'twenty-ui/icon'; @@ -67,7 +67,7 @@ export const RecordTableFieldsDropdownVisibleFieldsContent = ({ ) .toSorted(sortByProperty('position')); - const handleDragEnd = (result: DropResult) => { + const handleDragEnd = (result: DraggableListDropResult) => { if ( !result.destination || result.destination.index === 1 || diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/RecordTableSettingsFieldVisibility.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/RecordTableSettingsFieldVisibility.tsx index 313a156868..5786e1a909 100644 --- a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/RecordTableSettingsFieldVisibility.tsx +++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/RecordTableSettingsFieldVisibility.tsx @@ -3,7 +3,7 @@ import { useReorderRecordTableWidgetFields } from '@/page-layout/widgets/record- import { useToggleRecordTableWidgetFieldVisibility } from '@/page-layout/widgets/record-table/hooks/useToggleRecordTableWidgetFieldVisibility'; import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem'; import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList'; -import { type DropResult } from '@hello-pangea/dnd'; +import { type DraggableListDropResult } from '@/ui/layout/draggable-list/types/DraggableListDropResult'; import { styled } from '@linaria/react'; import { useMemo } from 'react'; import { IconEye, IconEyeOff, useIcons } from 'twenty-ui/icon'; @@ -65,7 +65,7 @@ export const RecordTableSettingsFieldVisibility = ({ [recordTableWidgetViewFieldItems], ); - const handleDragEnd = (result: DropResult) => { + const handleDragEnd = (result: DraggableListDropResult) => { const { source, destination } = result; if (!destination) { diff --git a/packages/twenty-front/src/modules/ui/layout/draggable-list/components/DraggableItem.tsx b/packages/twenty-front/src/modules/ui/layout/draggable-list/components/DraggableItem.tsx index 2aa3293da9..a46ed4644d 100644 --- a/packages/twenty-front/src/modules/ui/layout/draggable-list/components/DraggableItem.tsx +++ b/packages/twenty-front/src/modules/ui/layout/draggable-list/components/DraggableItem.tsx @@ -1,81 +1,80 @@ -import { Draggable } from '@hello-pangea/dnd'; +import { useDragDropMonitor } from '@dnd-kit/react'; import { isFunction } from '@sniptt/guards'; -import { type JSX, useContext } from 'react'; +import { type JSX, useContext, useEffect, useState } from 'react'; import { isDefined } from 'twenty-shared/utils'; -import { ThemeContext } from 'twenty-ui/theme-constants'; + +import { DraggableListGroupContext } from '@/ui/layout/draggable-list/contexts/DraggableListGroupContext'; +import { DragDropItemSortableCell } from '@/ui/utilities/drag-and-drop/components/DragDropItemSortableCell'; type DraggableItemProps = { draggableId: string; isDragDisabled?: boolean; - disableInteractiveElementBlocking?: boolean; index: number; itemComponent: | JSX.Element | ((props: { isDragging: boolean }) => JSX.Element); - isInsideScrollableContainer?: boolean; - draggableComponentStyles?: React.CSSProperties; disableDraggingBackground?: boolean; - containerOffsetY?: number; }; export const DraggableItem = ({ draggableId, isDragDisabled = false, - disableInteractiveElementBlocking = false, index, itemComponent, - isInsideScrollableContainer, - draggableComponentStyles, - disableDraggingBackground, - containerOffsetY, + disableDraggingBackground = false, }: DraggableItemProps) => { - const { theme } = useContext(ThemeContext); - return ( - - {(draggableProvided, draggableSnapshot) => { - const draggableStyle = draggableProvided.draggableProps.style; - const isDragging = draggableSnapshot.isDragging; + const draggableListGroupContext = useContext(DraggableListGroupContext); - return ( -
- {isFunction(itemComponent) - ? itemComponent({ - isDragging, - }) - : itemComponent} -
- ); - }} -
+ const [isDragging, setIsDragging] = useState(false); + + useDragDropMonitor({ + onDragStart: (event) => { + if (String(event.operation.source?.id) === draggableId) { + setIsDragging(true); + } + }, + onDragEnd: () => { + setIsDragging(false); + }, + }); + + // The list's end drop zone resolves its drop index from this registry, + // since only the rendered items know how many of them there are. + useEffect(() => { + if (!isDefined(draggableListGroupContext)) { + return; + } + + const itemIndexByDraggableId = + draggableListGroupContext.itemIndexByDraggableId; + + itemIndexByDraggableId.set(draggableId, index); + + return () => { + itemIndexByDraggableId.delete(draggableId); + }; + }, [draggableListGroupContext, draggableId, index]); + + if (!isDefined(draggableListGroupContext)) { + throw new Error('DraggableItem must be rendered inside a DraggableList'); + } + + const { group } = draggableListGroupContext; + + return ( + + {isFunction(itemComponent) + ? itemComponent({ isDragging }) + : itemComponent} + ); }; diff --git a/packages/twenty-front/src/modules/ui/layout/draggable-list/components/DraggableList.tsx b/packages/twenty-front/src/modules/ui/layout/draggable-list/components/DraggableList.tsx index 68a324b0bf..2a3d7cbd74 100644 --- a/packages/twenty-front/src/modules/ui/layout/draggable-list/components/DraggableList.tsx +++ b/packages/twenty-front/src/modules/ui/layout/draggable-list/components/DraggableList.tsx @@ -1,45 +1,119 @@ +import { DragDropProvider } from '@dnd-kit/react'; import { styled } from '@linaria/react'; -import { - DragDropContext, - Droppable, - type OnDragEndResponder, - type OnDragStartResponder, -} from '@hello-pangea/dnd'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; import { v4 } from 'uuid'; + +import { DRAGGABLE_LIST_END_DROP_INDEX } from '@/ui/layout/draggable-list/constants/DraggableListEndDropIndex'; +import { DraggableListGroupContext } from '@/ui/layout/draggable-list/contexts/DraggableListGroupContext'; +import { type DraggableListDropResult } from '@/ui/layout/draggable-list/types/DraggableListDropResult'; +import { DragDropItemEndDropZone } from '@/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone'; +import { DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION } from '@/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation'; +import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors'; +import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; +import { getDestinationIndex } from '@/ui/utilities/drag-and-drop/utils/getDestinationIndex'; + +type DraggableListItemDndData = { + droppableId: string; + index: number; +}; + type DraggableListProps = { draggableItems: React.ReactNode; - onDragEnd: OnDragEndResponder; - onDragStart?: OnDragStartResponder; + onDragEnd: (result: DraggableListDropResult) => void; }; const StyledDragDropItemsWrapper = styled.div` width: 100%; `; +// Catches drops after the last item; the negative margin cancels its +// footprint so the list keeps its height. +const StyledEndDropZone = styled(DragDropItemEndDropZone)` + height: ${themeCssVariables.spacing[2]}; + margin-bottom: calc(-1 * ${themeCssVariables.spacing[2]}); +`; + export const DraggableList = ({ draggableItems, onDragEnd, - onDragStart, }: DraggableListProps) => { - const [v4Persistable] = useState(v4()); + // The group id doubles as the items' dnd type, so drags from this list can't + // land on outer providers' targets (or the other way around). + const [group] = useState(() => v4()); + + // A mutable registry rather than render state: items write their index on + // mount so the end drop zone can resolve the append index at drop time. + const [itemIndexByDraggableId] = useState(() => new Map()); + + const groupContextValue = useMemo( + () => ({ group, itemIndexByDraggableId }), + [group, itemIndexByDraggableId], + ); + + const handleDragEnd = ( + event: DragDropProviderDragEndEvent, + ) => { + const source = event.operation.source; + const sourceData = source?.data as DraggableListItemDndData | undefined; + const targetData = event.operation.target?.data as + | DraggableListItemDndData + | undefined; + + if ( + event.canceled || + !isDefined(source) || + sourceData?.droppableId !== group || + targetData?.droppableId !== group + ) { + return; + } + + const dropTargetIndex = + targetData.index === DRAGGABLE_LIST_END_DROP_INDEX + ? itemIndexByDraggableId.size + : targetData.index; + + // The drop line renders before the hovered item, so a drop inserts the + // dragged item before it; convert that gap index into the final index. + const destinationIndex = getDestinationIndex({ + dropTargetIndex, + sourceIndex: sourceData.index, + sourceDroppableId: sourceData.droppableId, + destinationDroppableId: targetData.droppableId, + }); + + if (destinationIndex === sourceData.index) { + return; + } + + onDragEnd({ + draggableId: String(source.id), + source: { index: sourceData.index }, + destination: { index: destinationIndex }, + }); + }; return ( - - - - {(provided) => ( -
- {draggableItems} - {provided.placeholder} -
- )} -
-
-
+ + sensors={DND_KIT_SENSORS} + plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} + onDragEnd={handleDragEnd} + > + + + {draggableItems} + + + + ); }; diff --git a/packages/twenty-front/src/modules/ui/layout/draggable-list/components/__stories__/DraggableItem.stories.tsx b/packages/twenty-front/src/modules/ui/layout/draggable-list/components/__stories__/DraggableItem.stories.tsx index f99efef78d..d2ca720fd2 100644 --- a/packages/twenty-front/src/modules/ui/layout/draggable-list/components/__stories__/DraggableItem.stories.tsx +++ b/packages/twenty-front/src/modules/ui/layout/draggable-list/components/__stories__/DraggableItem.stories.tsx @@ -1,8 +1,8 @@ -import { DragDropContext, Droppable } from '@hello-pangea/dnd'; import { type Meta, type StoryObj } from '@storybook/react-vite'; import { fn } from 'storybook/test'; import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem'; +import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList'; import { IconBell } from 'twenty-ui/icon'; import { MenuItemDraggable } from 'twenty-ui/navigation'; import { ComponentDecorator } from 'twenty-ui/testing'; @@ -11,13 +11,7 @@ const meta: Meta = { title: 'UI/Layout/DraggableList/DraggableItem', component: DraggableItem, decorators: [ - (Story) => ( - - - {(_provided) => } - - - ), + (Story) => } />, ComponentDecorator, ], parameters: { diff --git a/packages/twenty-front/src/modules/ui/layout/draggable-list/constants/DraggableListEndDropIndex.ts b/packages/twenty-front/src/modules/ui/layout/draggable-list/constants/DraggableListEndDropIndex.ts new file mode 100644 index 0000000000..b169553a98 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/draggable-list/constants/DraggableListEndDropIndex.ts @@ -0,0 +1,3 @@ +// Sentinel index carried by the list's end drop zone; resolved to the item +// count at drop time since the zone cannot know how many items are rendered. +export const DRAGGABLE_LIST_END_DROP_INDEX = -1; diff --git a/packages/twenty-front/src/modules/ui/layout/draggable-list/contexts/DraggableListGroupContext.ts b/packages/twenty-front/src/modules/ui/layout/draggable-list/contexts/DraggableListGroupContext.ts new file mode 100644 index 0000000000..680a806669 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/draggable-list/contexts/DraggableListGroupContext.ts @@ -0,0 +1,9 @@ +import { createContext } from 'react'; + +export type DraggableListGroupContextValue = { + group: string; + itemIndexByDraggableId: Map; +}; + +export const DraggableListGroupContext = + createContext(null); diff --git a/packages/twenty-front/src/modules/ui/layout/draggable-list/types/DraggableListDropResult.ts b/packages/twenty-front/src/modules/ui/layout/draggable-list/types/DraggableListDropResult.ts new file mode 100644 index 0000000000..a40be56a4d --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/draggable-list/types/DraggableListDropResult.ts @@ -0,0 +1,5 @@ +export type DraggableListDropResult = { + draggableId: string; + source: { index: number }; + destination: { index: number } | null; +}; diff --git a/packages/twenty-front/src/modules/ui/layout/draggable-list/utils/getCssCompatibleDraggableProps.ts b/packages/twenty-front/src/modules/ui/layout/draggable-list/utils/getCssCompatibleDraggableProps.ts deleted file mode 100644 index 8e4220c2d4..0000000000 --- a/packages/twenty-front/src/modules/ui/layout/draggable-list/utils/getCssCompatibleDraggableProps.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { type DraggableProvidedDraggableProps } from '@hello-pangea/dnd'; -import { type CSSProperties } from 'react'; - -type CssCompatibleDraggableProps = Omit< - DraggableProvidedDraggableProps, - 'style' -> & { style?: CSSProperties }; - -// @hello-pangea/dnd types draggableProps.style as DraggingStyle | NotDraggingStyle — -// closed interfaces that do not satisfy the `--radix-${string}` index signature -// @radix-ui/react-popper augments onto React.CSSProperties. Widen the style so the -// props can be spread onto a styled element without a per-call-site cast. -export const getCssCompatibleDraggableProps = ( - draggableProps: DraggableProvidedDraggableProps, -): CssCompatibleDraggableProps => draggableProps as CssCompatibleDraggableProps; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemDropLine.tsx b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemDropLine.tsx new file mode 100644 index 0000000000..b5ab2030b1 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemDropLine.tsx @@ -0,0 +1,46 @@ +import { styled } from '@linaria/react'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +type DragDropItemDropLineOrientation = 'horizontal' | 'vertical'; + +// Absolutely positioned over the leading edge of its (position: relative) +// parent so activating the drop target does not reflow the list. Horizontal +// straddles the item's top boundary (vertical lists); vertical draws on its +// left (horizontal lists). +const StyledDropLineContainer = styled.div<{ + $orientation: DragDropItemDropLineOrientation; +}>` + bottom: ${({ $orientation }) => ($orientation === 'vertical' ? '0' : 'auto')}; + left: ${({ $orientation }) => + $orientation === 'vertical' + ? `calc(-1 * ${themeCssVariables.spacing[1]})` + : '0'}; + position: absolute; + right: ${({ $orientation }) => ($orientation === 'vertical' ? 'auto' : '0')}; + top: ${({ $orientation }) => ($orientation === 'vertical' ? '0' : '-1px')}; +`; + +const StyledDropLine = styled.div<{ + $orientation: DragDropItemDropLineOrientation; +}>` + background-color: ${themeCssVariables.color.blue}; + border-radius: ${themeCssVariables.border.radius.sm}; + height: ${({ $orientation }) => + $orientation === 'vertical' ? '100%' : '2px'}; + width: ${({ $orientation }) => + $orientation === 'vertical' ? '2px' : '100%'}; +`; + +type DragDropItemDropLineProps = { + orientation?: DragDropItemDropLineOrientation; + className?: string; +}; + +export const DragDropItemDropLine = ({ + orientation = 'horizontal', + className, +}: DragDropItemDropLineProps) => ( + + + +); diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone.tsx b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone.tsx new file mode 100644 index 0000000000..b9a05e064d --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone.tsx @@ -0,0 +1,45 @@ +import { pointerIntersection } from '@dnd-kit/collision'; +import { useDroppable } from '@dnd-kit/react'; +import { styled } from '@linaria/react'; +import { type ReactNode } from 'react'; + +import { DragDropItemDropLine } from '@/ui/utilities/drag-and-drop/components/DragDropItemDropLine'; + +const StyledEndDropZone = styled.div` + position: relative; +`; + +type DragDropItemEndDropZoneProps = { + id: string; + accept: string; + data: Record; + dropLine?: 'horizontal' | 'vertical'; + className?: string; + children?: ReactNode; +}; + +// Catches drops after the last sortable item of a list, or into an empty +// list, where there is no item cell to target. Style with styled(...) to give +// the zone its layout footprint. +export const DragDropItemEndDropZone = ({ + id, + accept, + data, + dropLine = 'horizontal', + className, + children, +}: DragDropItemEndDropZoneProps) => { + const { ref, isDropTarget } = useDroppable({ + id, + accept, + collisionDetector: pointerIntersection, + data, + }); + + return ( + + {isDropTarget && } + {children} + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemSortableCell.tsx b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemSortableCell.tsx index 8a2ede4f0b..a6e98c767f 100644 --- a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemSortableCell.tsx +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemSortableCell.tsx @@ -2,25 +2,36 @@ import { RestrictToHorizontalAxis, RestrictToVerticalAxis, } from '@dnd-kit/abstract/modifiers'; -import { SortableKeyboardPlugin } from '@dnd-kit/dom/sortable'; import { useSortable } from '@dnd-kit/react/sortable'; import { styled } from '@linaria/react'; import { type ReactNode } from 'react'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; +import { DragDropItemDropLine } from '@/ui/utilities/drag-and-drop/components/DragDropItemDropLine'; +import { DND_KIT_PLUGINS_WITHOUT_OPTIMISTIC } from '@/ui/utilities/drag-and-drop/constants/DndKitPluginsWithoutOptimistic'; import { DragDropItemSortableHandleRefContext } from '@/ui/utilities/drag-and-drop/context/DragDropItemSortableHandleRefContext'; import { preventNativeDragStart } from '@/ui/utilities/drag-and-drop/utils/preventNativeDragStart'; const SORTABLE_COLLISION_PRIORITY = 3; -const PLUGINS_WITHOUT_OPTIMISTIC = [SortableKeyboardPlugin]; - const SORTABLE_TRANSITION = { duration: 180, easing: 'cubic-bezier(0.2, 0, 0, 1)', idle: true, }; -const StyledSortableRoot = styled.div<{ $fill?: boolean }>` +const StyledSortableRoot = styled.div<{ + $disabled?: boolean; + $fill?: boolean; + $isDraggingHighlighted?: boolean; +}>` + background: ${({ $isDraggingHighlighted }) => + $isDraggingHighlighted + ? themeCssVariables.background.transparent.light + : 'transparent'}; + border-radius: ${({ $isDraggingHighlighted }) => + $isDraggingHighlighted ? themeCssVariables.border.radius.sm : '0'}; + cursor: ${({ $disabled }) => ($disabled ? 'inherit' : 'grab')}; display: ${({ $fill }) => ($fill ? 'flex' : 'block')}; flex-shrink: ${({ $fill }) => ($fill ? 0 : 'initial')}; height: ${({ $fill }) => ($fill ? '100%' : 'auto')}; @@ -28,60 +39,87 @@ const StyledSortableRoot = styled.div<{ $fill?: boolean }>` min-width: ${({ $fill }) => ($fill ? '0' : 'auto')}; outline: none; position: relative; + transition: background 0.1s ease; will-change: transform; + + /* When the cell delegates dragging to an explicit handle, only the handle + is grabbable, so the rest of the cell keeps its ambient cursor. */ + &:has([data-dnd-sortable-handle]) { + cursor: inherit; + } `; type DragDropItemSortableCellProps = { accept?: string; children: ReactNode; + data?: Record; disabled?: boolean; fill?: boolean; group: string; + hasTransition?: boolean; + highlightWhileDragging?: boolean; id: string; index: number; restrictMovementTo?: 'x' | 'y' | 'none'; + dropLine?: 'horizontal' | 'vertical' | 'none'; type?: string; }; export const DragDropItemSortableCell = ({ accept, children, + data, disabled = false, fill = false, group, + hasTransition = true, + highlightWhileDragging = false, id, index, restrictMovementTo = 'none', + dropLine = 'none', type, }: DragDropItemSortableCellProps) => { - const { handleRef, ref } = useSortable({ - id, - index, - group, - type, - accept, - collisionPriority: SORTABLE_COLLISION_PRIORITY, - data: { - droppableId: group, + const { handleRef, ref, isDragging, isDragSource, isDropTarget } = + useSortable({ + id, index, - }, - disabled, - transition: SORTABLE_TRANSITION, - plugins: PLUGINS_WITHOUT_OPTIMISTIC, - modifiers: [ - ...(restrictMovementTo === 'x' ? [RestrictToHorizontalAxis] : []), - ...(restrictMovementTo === 'y' ? [RestrictToVerticalAxis] : []), - ], - feedback: 'clone', - }); + group, + type, + accept, + collisionPriority: SORTABLE_COLLISION_PRIORITY, + // Sortable metadata stays authoritative over consumer data so drag + // handlers always resolve the cell's real group and position. + data: { + ...data, + droppableId: group, + index, + }, + disabled, + transition: hasTransition ? SORTABLE_TRANSITION : null, + plugins: DND_KIT_PLUGINS_WITHOUT_OPTIMISTIC, + modifiers: [ + ...(restrictMovementTo === 'x' ? [RestrictToHorizontalAxis] : []), + ...(restrictMovementTo === 'y' ? [RestrictToVerticalAxis] : []), + ], + feedback: 'clone', + }); + + // The drag source is its own initial drop target; rendering the line on it + // would bake a stale copy into the placeholder clone taken at drag start. + const shouldShowDropLine = + dropLine !== 'none' && isDropTarget && !isDragSource; return ( + {shouldShowDropLine && } {children} diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemSortableHandle.tsx b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemSortableHandle.tsx index b300077716..0a364ad228 100644 --- a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemSortableHandle.tsx +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemSortableHandle.tsx @@ -5,6 +5,7 @@ import { themeCssVariables } from 'twenty-ui/theme-constants'; import { DragDropItemSortableHandleRefContext } from '@/ui/utilities/drag-and-drop/context/DragDropItemSortableHandleRefContext'; const StyledSortableHandle = styled.div<{ $fill?: boolean }>` + cursor: grab; display: ${({ $fill }) => ($fill ? 'flex' : 'block')}; height: 100%; min-width: 0; @@ -28,7 +29,11 @@ export const DragDropItemSortableHandle = ({ const sortableHandleRef = useContext(DragDropItemSortableHandleRefContext); return ( - + {children} ); diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/constants/DndKitPluginsWithoutOptimistic.ts b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/constants/DndKitPluginsWithoutOptimistic.ts new file mode 100644 index 0000000000..a299d028ae --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/constants/DndKitPluginsWithoutOptimistic.ts @@ -0,0 +1,6 @@ +import { SortableKeyboardPlugin } from '@dnd-kit/dom/sortable'; + +// Deliberately excludes dnd-kit's optimistic sorting plugin: application state +// stays the single source of truth for item order, so lists only reorder once +// a drop is committed. +export const DND_KIT_PLUGINS_WITHOUT_OPTIMISTIC = [SortableKeyboardPlugin]; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation.ts b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation.ts new file mode 100644 index 0000000000..61723defc2 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation.ts @@ -0,0 +1,11 @@ +import { defaultPreset, Feedback } from '@dnd-kit/dom'; + +// Twenty applies reorders to application state the moment a drop commits, so +// dnd-kit's default drop animation, which flies the dragged element back to +// its pre-drag position, reads as the drop being reverted. The animating +// element is also position: fixed with an infinite z-index, so it paints over +// overlays such as the record side panel while it flies back. +export const DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION = + defaultPreset.plugins.map((plugin) => + plugin === Feedback ? Feedback.configure({ dropAnimation: null }) : plugin, + ); diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent.ts b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent.ts new file mode 100644 index 0000000000..aed810e2b6 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent.ts @@ -0,0 +1,7 @@ +import { type Data } from '@dnd-kit/abstract'; + +import { type DragDropProviderProps } from '@/ui/utilities/drag-and-drop/types/DragDropProviderProps'; + +export type DragDropProviderDragEndEvent = Parameters< + NonNullable['onDragEnd']> +>[0]; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDragMoveEvent.ts b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDragMoveEvent.ts new file mode 100644 index 0000000000..9609083d2d --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDragMoveEvent.ts @@ -0,0 +1,7 @@ +import { type Data } from '@dnd-kit/abstract'; + +import { type DragDropProviderProps } from '@/ui/utilities/drag-and-drop/types/DragDropProviderProps'; + +export type DragDropProviderDragMoveEvent = Parameters< + NonNullable['onDragMove']> +>[0]; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDragOverEvent.ts b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDragOverEvent.ts new file mode 100644 index 0000000000..1d4d4ba332 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDragOverEvent.ts @@ -0,0 +1,7 @@ +import { type Data } from '@dnd-kit/abstract'; + +import { type DragDropProviderProps } from '@/ui/utilities/drag-and-drop/types/DragDropProviderProps'; + +export type DragDropProviderDragOverEvent = Parameters< + NonNullable['onDragOver']> +>[0]; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDragStartEvent.ts b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDragStartEvent.ts new file mode 100644 index 0000000000..4e969f90e3 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDragStartEvent.ts @@ -0,0 +1,7 @@ +import { type Data } from '@dnd-kit/abstract'; + +import { type DragDropProviderProps } from '@/ui/utilities/drag-and-drop/types/DragDropProviderProps'; + +export type DragDropProviderDragStartEvent = Parameters< + NonNullable['onDragStart']> +>[0]; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDropTarget.ts b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDropTarget.ts new file mode 100644 index 0000000000..a031809a4f --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDropTarget.ts @@ -0,0 +1,6 @@ +import { type Data } from '@dnd-kit/abstract'; + +import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; + +export type DragDropProviderDropTarget = + DragDropProviderDragEndEvent['operation']['target']; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderProps.ts b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderProps.ts new file mode 100644 index 0000000000..9ebe51d26e --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderProps.ts @@ -0,0 +1,7 @@ +import { type Data } from '@dnd-kit/abstract'; +import { type DragDropProvider } from '@dnd-kit/react'; +import { type ComponentProps } from 'react'; + +export type DragDropProviderProps = ComponentProps< + typeof DragDropProvider +>; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/resolveDropFromPointerY.ts b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/resolveDropFromPointerY.ts index 62f8abf883..cc7c16238d 100644 --- a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/resolveDropFromPointerY.ts +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/resolveDropFromPointerY.ts @@ -1,16 +1,11 @@ -import { type DragDropProvider } from '@dnd-kit/react'; import { isSortable } from '@dnd-kit/react/sortable'; -import { type ComponentProps } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { type DragDropItemData } from '@/ui/utilities/drag-and-drop/types/DragDropItemData'; +import { type DragDropProviderDropTarget } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDropTarget'; import { resolveDropTarget } from '@/ui/utilities/drag-and-drop/utils/resolveDropTarget'; -type DropTarget = Parameters< - NonNullable< - ComponentProps>['onDragEnd'] - > ->[0]['operation']['target']; +type DropTarget = DragDropProviderDropTarget; export type ResolvedDrop = { droppableId: string; diff --git a/packages/twenty-front/src/modules/views/components/ViewFieldsVisibleDropdownSection.tsx b/packages/twenty-front/src/modules/views/components/ViewFieldsVisibleDropdownSection.tsx index 793b5d63b2..c2cd6bbd31 100644 --- a/packages/twenty-front/src/modules/views/components/ViewFieldsVisibleDropdownSection.tsx +++ b/packages/twenty-front/src/modules/views/components/ViewFieldsVisibleDropdownSection.tsx @@ -1,4 +1,4 @@ -import { type DropResult, type ResponderProvided } from '@hello-pangea/dnd'; +import { type DraggableListDropResult } from '@/ui/layout/draggable-list/types/DraggableListDropResult'; import { useGetFieldMetadataItemByIdOrThrow } from '@/object-metadata/hooks/useGetFieldMetadataItemById'; import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem'; @@ -10,9 +10,7 @@ import { visibleRecordFieldsComponentSelector } from '@/object-record/record-fie import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem'; import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; -import { dropdownYPositionComponentState } from '@/ui/layout/dropdown/states/internal/dropdownYPositionComponentState'; import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; -import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { ViewType } from '@/views/types/ViewType'; import { useContext } from 'react'; import { isDefined } from 'twenty-shared/utils'; @@ -51,8 +49,8 @@ export const ViewFieldsVisibleDropdownSection = () => { ? handleBoardFieldVisibilityChange : changeRecordFieldVisibility; - const handleDragEnd = (result: DropResult, provided: ResponderProvided) => { - handleReorderFields(result, provided); + const handleDragEnd = (result: DraggableListDropResult) => { + handleReorderFields(result); }; const { getIcon } = useIcons(); @@ -78,10 +76,6 @@ export const ViewFieldsVisibleDropdownSection = () => { ) .toSorted(sortByProperty('position')); - const dropdownYPosition = useAtomComponentStateValue( - dropdownYPositionComponentState, - ); - return ( <> @@ -113,8 +107,6 @@ export const ViewFieldsVisibleDropdownSection = () => { key={recordField.fieldMetadataItemId} draggableId={recordField.fieldMetadataItemId} index={fieldIndex + 1} - isInsideScrollableContainer - containerOffsetY={dropdownYPosition} itemComponent={ { }; const handleWorkspaceDragEnd = useCallback( - async (result: DropResult) => { + async (result: DraggableListDropResult) => { if (!result.destination) return; const viewsReordered = moveArrayItem(workspaceViews, { @@ -112,7 +112,7 @@ export const ViewPickerListContent = () => { ); const handleUnlistedDragEnd = useCallback( - async (result: DropResult) => { + async (result: DraggableListDropResult) => { if (!result.destination) return; const viewsReordered = moveArrayItem(unlistedViews, { diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder.tsx index 1178a2758a..11d68846fe 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder.tsx @@ -5,6 +5,7 @@ import { FormFieldPlaceholder } from '@/object-record/record-field/ui/form-types import { InputLabel } from '@/ui/input/components/InputLabel'; import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem'; import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList'; +import { type DraggableListDropResult } from '@/ui/layout/draggable-list/types/DraggableListDropResult'; import { type WorkflowFormAction, type WorkflowTriggerType, @@ -14,7 +15,6 @@ import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/Workflo import { WorkflowEditActionFormFieldSettings } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormFieldSettings'; import { type WorkflowFormActionField } from '@/workflow/workflow-steps/workflow-actions/form-action/types/WorkflowFormActionField'; import { getDefaultFormFieldSettings } from '@/workflow/workflow-steps/workflow-actions/form-action/utils/getDefaultFormFieldSettings'; -import { type OnDragEndResponder } from '@hello-pangea/dnd'; import { styled } from '@linaria/react'; import { useLingui } from '@lingui/react/macro'; import { isNonEmptyString } from '@sniptt/guards'; @@ -57,6 +57,7 @@ const StyledFormFieldContainer = styled.div` 'grip input delete' '. settings .'; grid-template-columns: 24px 1fr 24px; + margin-bottom: ${themeCssVariables.spacing[4]}; position: relative; `; @@ -191,7 +192,7 @@ export const WorkflowEditActionFormBuilder = ({ saveAction(updatedFormData); }; - const handleDragEnd: OnDragEndResponder = ({ source, destination }) => { + const handleDragEnd = ({ source, destination }: DraggableListDropResult) => { if (actionOptions.readonly === true) { return; } @@ -279,11 +280,7 @@ export const WorkflowEditActionFormBuilder = ({ draggableId={field.id} index={index} isDragDisabled={actionOptions.readonly} - isInsideScrollableContainer disableDraggingBackground - draggableComponentStyles={{ - marginBottom: themeCssVariables.spacing[4], - }} itemComponent={({ isDragging }) => { const showButtons = !actionOptions.readonly && diff --git a/yarn.lock b/yarn.lock index 7ab3d871b6..82088c1174 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4456,7 +4456,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.26.7, @babel/runtime@npm:^7.27.6, @babel/runtime@npm:^7.29.2": +"@babel/runtime@npm:^7.27.6, @babel/runtime@npm:^7.29.2": version: 7.29.7 resolution: "@babel/runtime@npm:7.29.7" checksum: 10c0/ca11572f7146b21e0bde6a9ed4bb6a89eafbee5f0944c7eb54d0d8a2dac962c33638a1d611e14faa71dfbb92b4b5f9236232208568a6b7d5c6f3f39ddb91771e @@ -7996,22 +7996,6 @@ __metadata: languageName: node linkType: hard -"@hello-pangea/dnd@npm:^18.0.1": - version: 18.0.1 - resolution: "@hello-pangea/dnd@npm:18.0.1" - dependencies: - "@babel/runtime": "npm:^7.26.7" - css-box-model: "npm:^1.2.1" - raf-schd: "npm:^4.0.3" - react-redux: "npm:^9.2.0" - redux: "npm:^5.0.1" - peerDependencies: - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - checksum: 10c0/30c47ac8048f85e5c6d39c0b5a492cf2cc9e5f532cee12c5ecc77688596c8846670be142bd716212db789f161cd769601a5da135fa99ac65824fbb6a07d4d137 - languageName: node - linkType: hard - "@hookform/resolvers@npm:^5.2.2": version: 5.2.2 resolution: "@hookform/resolvers@npm:5.2.2" @@ -29682,15 +29666,6 @@ __metadata: languageName: node linkType: hard -"css-box-model@npm:^1.2.1": - version: 1.2.1 - resolution: "css-box-model@npm:1.2.1" - dependencies: - tiny-invariant: "npm:^1.0.6" - checksum: 10c0/611e56d76b16e4e21956ed9fa53f1936fbbfaccd378659587e9c929f342037fc6c062f8af9447226e11fe7c95e31e6c007a37e592f9bff4c2d40e6915553104a - languageName: node - linkType: hard - "css-color-keywords@npm:^1.0.0": version: 1.0.0 resolution: "css-color-keywords@npm:1.0.0" @@ -46711,13 +46686,6 @@ __metadata: languageName: node linkType: hard -"raf-schd@npm:^4.0.3": - version: 4.0.3 - resolution: "raf-schd@npm:4.0.3" - checksum: 10c0/ecabf0957c05fad059779bddcd992f1a9d3a35dfea439a6f0935c382fcf4f7f7fa60489e467b4c2db357a3665167d2a379782586b59712bb36c766e02824709b - languageName: node - linkType: hard - "ramda@npm:^0.27.1": version: 0.27.2 resolution: "ramda@npm:0.27.2" @@ -47220,25 +47188,6 @@ __metadata: languageName: node linkType: hard -"react-redux@npm:^9.2.0": - version: 9.3.0 - resolution: "react-redux@npm:9.3.0" - dependencies: - "@types/use-sync-external-store": "npm:^0.0.6" - use-sync-external-store: "npm:^1.4.0" - peerDependencies: - "@types/react": ^18.2.25 || ^19 - react: ^18.0 || ^19 - redux: ^5.0.0 - peerDependenciesMeta: - "@types/react": - optional: true - redux: - optional: true - checksum: 10c0/b9f4efcfbfbc90cac9d1709ab3affb1e18a9dc9bd3cceda43bd2e1d9d2394ee0c29df36ec2202d52b566db774888b594c8c5aa86b64f27ef34fca607c687c9e3 - languageName: node - linkType: hard - "react-remove-scroll-bar@npm:^2.3.7": version: 2.3.8 resolution: "react-remove-scroll-bar@npm:2.3.8" @@ -47699,13 +47648,6 @@ __metadata: languageName: node linkType: hard -"redux@npm:^5.0.1": - version: 5.0.1 - resolution: "redux@npm:5.0.1" - checksum: 10c0/b10c28357194f38e7d53b760ed5e64faa317cc63de1fb95bc5d9e127fab956392344368c357b8e7a9bedb0c35b111e7efa522210cfdc3b3c75e5074718e9069c - languageName: node - linkType: hard - "reflect-metadata@npm:0.2.2, reflect-metadata@npm:^0.2.2": version: 0.2.2 resolution: "reflect-metadata@npm:0.2.2" @@ -52045,7 +51987,7 @@ __metadata: languageName: node linkType: hard -"tiny-invariant@npm:^1.0.0, tiny-invariant@npm:^1.0.6, tiny-invariant@npm:^1.3.3": +"tiny-invariant@npm:^1.0.0, tiny-invariant@npm:^1.3.3": version: 1.3.3 resolution: "tiny-invariant@npm:1.3.3" checksum: 10c0/65af4a07324b591a059b35269cd696aba21bef2107f29b9f5894d83cc143159a204b299553435b03874ebb5b94d019afa8b8eff241c8a4cfee95872c2e1c1c4a @@ -52872,7 +52814,6 @@ __metadata: "@graphql-codegen/typed-document-node": "npm:^6.1.8" "@graphql-codegen/typescript": "npm:^5.0.10" "@graphql-codegen/typescript-operations": "npm:^5.1.0" - "@hello-pangea/dnd": "npm:^18.0.1" "@hookform/resolvers": "npm:^5.2.2" "@linaria/core": "npm:^7.0.0" "@linaria/react": "npm:^7.0.1"