Converge drag and drop on shared dnd-kit primitives, remove @hello-pangea/dnd (#23211)

Follow-ups recorded in #23023, done in one pass.

## Shared primitives

- Folded `PageLayoutWidgetSortableItem` and `PageLayoutWidgetDropLine`
into the shared `DragDropItemSortableCell` / new `DragDropItemDropLine`
(new `data`, `dropLine`, `highlightWhileDragging`, `hasTransition`
props).
- Added generic `DragDropProviderDragStartEvent` (and
DragMove/DragOver/DragEnd/DropTarget) helpers and deleted the 7 copied
`Parameters<...>` extractions across the dnd hooks.
- Replaced the `useMovePageLayoutWidgetUp/Down` implementations (~140
lines) with `moveWidgetWithinTabInDraft`.
- Migrated the remaining page-layout test suites onto
`pageLayoutDraftFixtures`.

## Tab reordering off Pangea

- Tabs are sortable cells on the same provider as widget drags,
segregated by dnd type, so widget drops on tab buttons keep working
while tabs reorder.
- Reordering is ID based (`reorderTabInDraft`: insert before the hovered
tab), which keeps the pinned first tab in place without index
arithmetic.
- Preserved overflow behaviors: the dropdown stays open while a tab drag
is in flight, dropping a tab on the "+N More" button appends it and
opens the dropdown, and both the visible strip and the overflow list
have end drop zones.

## Fields configuration editors off Pangea

- Group reorder, field reorder and cross-group field moves now run on
the shared cells (same drop line and end-zone patterns).

## DraggableList off Pangea

- `DraggableList` / `DraggableItem` keep their consumer-facing API — the
~9 consumers now type their handlers with a local
`DraggableListDropResult` instead of pangea's `DropResult` — but run on
the shared sortable cells; each list's uuid group doubles as its dnd
type so nested lists stay isolated from page-level providers.
- Items register their index in a list-scoped registry so the end drop
zone can resolve the append index at drop time (with insert-before
semantics an item could otherwise never reach the last position).
- Deleted three dead files that only existed for pangea plumbing (the
side panel navigation placeholder, `getCssCompatibleDraggableProps`, the
orphaned `recordGroupPendingDragEndReorderState`).

## Record table row drag off Pangea

- Rows register through `useSortable` directly on the row element — no
wrapper div, so row CSS, sticky cells and virtualization stay untouched
— with the grip cell wired as the drag handle via the shared sortable
handle ref context.
- Both table modes (virtualized flat list and record groups) share a
`DragOverlay` clone that replaces pangea's virtual-mode `renderClone`,
and end drop zones per record group (and after the virtualized list)
allow dropping after the last row or into an empty group.
- The drop handlers keep their pangea-shaped result object, retyped as a
local `RecordDragDropResult`, so the position computation logic is
untouched.

## Pangea removed

`@hello-pangea/dnd` is gone from `package.json` and the lockfile, along
with its orphaned transitive entries (`css-box-model`, `raf-schd`,
`react-redux`, `redux`). Nothing in the repo imports it anymore.

## Dashboards: cross-tab widget drag for grids

react-grid-layout drags never enter dnd-kit, so the bridge hit-tests the
pointer against the tab buttons' `data-page-layout-tab-drop-target-id`
rects during grid drags, highlights the hovered tab through state, and
on drop moves the widget to the destination grid below its existing
content (`moveWidgetToGridTabInDraft`, `buildTabWidgetLayouts`). The
grid's own post-drag layout commit is suppressed once so it does not
overwrite the cross-tab move.

## Fixes found while testing

- With `feedback: 'clone'`, the drag source is its own initial drop
target and its placeholder is a DOM clone taken at drag start, so the
drop line rendered into the source got baked into the placeholder and
stuck there for the whole drag. The line is now hidden on the source
cell, leaving a single indicator at the actual target.
- Reorderable tabs collapsed to text height and sat top-aligned next to
"+ New Tab" because the sortable cell wrapper defaults to `display:
block; height: auto`, breaking the tab height chain — the tab list now
uses the cell's `fill` mode so tabs stretch to the strip height again.

## Testing

Playwright against the dev app:
- Record page: widget reorder up and down in the pinned column (single
blue drop line at the target), drag to another tab via its tab button
(highlight + move), drag back into content at a specific position,
chained cross-tab moves, tab reorder with vertical drop line, new tab
creation.
- Overflow (narrow viewport): drop a tab on "+N More" (appends last,
dropdown opens), reorder inside the dropdown (stays open), drag a tab
from the dropdown back to the visible strip.
- Dashboard: grid drag within a tab, cross-tab drag onto a tab button
(hover highlight, widget lands below destination content, remaining
widgets keep their positions), save and reload persistence in both
directions.
- Fields editor: field reorder, group reorder, field move across groups,
plus the Move Up / Move Down widget actions.

Since the pangea-removal commits:
- Typecheck, oxlint and oxfmt green over the full front source; unit
suites green including the migrated `useStartRecordDrag` test (jest
needed a scoped transform exemption for `@preact/signals-core` once
dnd-kit reached the side-panel suites).
- Storybook visual regression unchanged across ~700 stories — expected,
since the migrated surfaces render identical DOM at rest (drop lines and
drag overlays only exist mid-drag).
- The tab strip fix reverses the exact regression mechanism: the
sortable cell wrapper defaulted to `display: block; height: auto`,
collapsing the tab height chain next to the full-height "+ New Tab"
button; `fill` restores the stretch.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01XKRCzzu8oGyocXZtFp7VEG)_


<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23211?utm_source=github"
rel="nofollow noreferrer noopener" target="_blank">``&lt;img alt="Review
in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"&gt;``</a>
This commit is contained in:
Félix Malfait
2026-07-24 14:04:13 +02:00
committed by GitHub
parent d53ef11fe2
commit 3a8f086d15
141 changed files with 2648 additions and 2303 deletions
+2 -2
View File
@@ -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: {
-1
View File
@@ -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",
+13
View File
@@ -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 {
@@ -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)) {
@@ -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<typeof DragDropProvider<DraggableData>>['onDragStart']
>
>[0];
type DragOverPayload = Parameters<
NonNullable<
ComponentProps<typeof DragDropProvider<DraggableData>>['onDragOver']
>
>[0];
type DragEndPayload = Parameters<
NonNullable<
ComponentProps<typeof DragDropProvider<DraggableData>>['onDragEnd']
>
>[0];
type DragStartPayload = DragDropProviderDragStartEvent<DraggableData>;
type DragOverPayload = DragDropProviderDragOverEvent<DraggableData>;
type DragEndPayload = DragDropProviderDragEndEvent<DraggableData>;
export type NavigationMenuItemDndKitContextValues = {
dragSource: { sourceDroppableId: string | null };
@@ -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 = ({
<NavigationDropTargetContext.Provider value={contextValues.dropTarget}>
<DragDropProvider<DraggableData>
sensors={DND_KIT_SENSORS}
plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION}
onDragStart={handlers.onDragStart}
onDragOver={handlers.onDragOver}
onDragEnd={handlers.onDragEnd}
@@ -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;
}
@@ -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 ||
@@ -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 =
@@ -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<typeof DragDropProvider<DragDropItemData>>['onDragStart']
>
>[0];
type DragMovePayload = Parameters<
NonNullable<
ComponentProps<typeof DragDropProvider<DragDropItemData>>['onDragMove']
>
>[0];
type DragEndPayload = Parameters<
NonNullable<
ComponentProps<typeof DragDropProvider<DragDropItemData>>['onDragEnd']
>
>[0];
type DragStartPayload = DragDropProviderDragStartEvent<DragDropItemData>;
type DragMovePayload = DragDropProviderDragMoveEvent<DragDropItemData>;
type DragEndPayload = DragDropProviderDragEndEvent<DragDropItemData>;
type PendingReorder = {
fromIndex: number;
@@ -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 = ({
<DragDropItemDndContext.Provider value={contextValues}>
<DragDropProvider<DragDropItemData>
sensors={isRecordBoardViewSettingsReadOnly ? [] : DND_KIT_SENSORS}
plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION}
onDragStart={handlers.onDragStart}
onDragMove={handlers.onDragMove}
onDragEnd={handlers.onDragEnd}
@@ -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<typeof DragDropProvider<DragDropItemData>>['onDragStart']
>
>[0];
type DragMovePayload = Parameters<
NonNullable<
ComponentProps<typeof DragDropProvider<DragDropItemData>>['onDragMove']
>
>[0];
type DragEndPayload = Parameters<
NonNullable<
ComponentProps<typeof DragDropProvider<DragDropItemData>>['onDragEnd']
>
>[0];
type DragStartPayload = DragDropProviderDragStartEvent<DragDropItemData>;
type DragMovePayload = DragDropProviderDragMoveEvent<DragDropItemData>;
type DragEndPayload = DragDropProviderDragEndEvent<DragDropItemData>;
export type RecordBoardDndKitContextValues = {
activeDropTargetIndex: number | null;
@@ -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 = ({
<DragDropItemDndContext.Provider value={contextValues}>
<DragDropProvider<DragDropItemData>
sensors={DND_KIT_SENSORS}
plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION}
onDragStart={handlers.onDragStart}
onDragMove={handlers.onDragMove}
onDragEnd={handlers.onDragEnd}
@@ -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 = ({
<DragDropItemDndContext.Provider value={contextValues}>
<DragDropProvider<DragDropItemData>
sensors={DND_KIT_SENSORS}
plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION}
onDragStart={handlers.onDragStart}
onDragMove={handlers.onDragMove}
onDragEnd={handlers.onDragEnd}
@@ -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<typeof DragDropProvider<DragDropItemData>>['onDragStart']
>
>[0];
type DragMovePayload = Parameters<
NonNullable<
ComponentProps<typeof DragDropProvider<DragDropItemData>>['onDragMove']
>
>[0];
type DragEndPayload = Parameters<
NonNullable<
ComponentProps<typeof DragDropProvider<DragDropItemData>>['onDragEnd']
>
>[0];
type DragStartPayload = DragDropProviderDragStartEvent<DragDropItemData>;
type DragMovePayload = DragDropProviderDragMoveEvent<DragDropItemData>;
type DragEndPayload = DragDropProviderDragEndEvent<DragDropItemData>;
export type RecordCalendarDndKitContextValues = {
activeDropTargetIndex: number | null;
@@ -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<RecordCalendarWeekDndData>
>['onDragStart']
>
>[0];
type DragEndPayload = Parameters<
NonNullable<
ComponentProps<
typeof DragDropProvider<RecordCalendarWeekDndData>
>['onDragEnd']
>
>[0];
type DragStartPayload =
DragDropProviderDragStartEvent<RecordCalendarWeekDndData>;
type DragEndPayload = DragDropProviderDragEndEvent<RecordCalendarWeekDndData>;
type RecordCalendarWeekDragDropContextProps = {
children: ReactNode;
@@ -106,6 +93,7 @@ export const RecordCalendarWeekDragDropContext = ({
return (
<DragDropProvider<RecordCalendarWeekDndData>
sensors={DND_KIT_SENSORS}
plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
@@ -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);
@@ -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;
@@ -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) {
@@ -0,0 +1,11 @@
export type RecordDragDropResult = {
draggableId: string;
source: {
droppableId: string;
index: number;
};
destination: {
droppableId: string;
index: number;
} | null;
};
@@ -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<HTMLDivElement>(null);
@@ -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<Parameters<OnDragEndResponder> | null>(null);
useState<DraggableListDropResult | null>(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();
};
@@ -1,9 +0,0 @@
import { type OnDragEndResponder } from '@hello-pangea/dnd';
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const recordGroupPendingDragEndReorderState =
createAtomState<Parameters<OnDragEndResponder> | null>({
key: 'recordGroupPendingDragEndReorderState',
defaultValue: null,
});
@@ -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 = () => {
/>
);
})}
<RecordTableNoRecordGroupAddNew />
<DragDropItemEndDropZone
id="record-table-no-record-group-end-drop-zone"
accept={RECORD_TABLE_ROW_DND_TYPE}
data={{
droppableId: RECORD_TABLE_NO_RECORD_GROUP_DROPPABLE_ID,
index: totalNumberOfRecordsToVirtualize,
}}
>
<RecordTableNoRecordGroupAddNew />
</DragDropItemEndDropZone>
<RecordTableVirtualizedDebugHelper />
</StyledNoRecordGroupContainer>
);
@@ -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 = () => {
/>
);
})}
<RecordTableBodyDroppablePlaceholder />
<RecordTableRecordGroupSectionLoadMore />
<RecordTableRecordGroupSectionAddNew />
<StyledRecordGroupEndDropZone
id={`record-group-end-drop-zone-${currentRecordGroupId}`}
accept={RECORD_TABLE_ROW_DND_TYPE}
data={{
droppableId: currentRecordGroupId,
index: recordIndexRecordIdsByGroup.length,
}}
>
<RecordTableRecordGroupSectionLoadMore />
<RecordTableRecordGroupSectionAddNew />
</StyledRecordGroupEndDropZone>
<RecordTableAggregateFooter
key={currentRecordGroupId}
currentRecordGroupId={currentRecordGroupId}
@@ -210,7 +210,6 @@ const meta: Meta = {
<RecordTableRowDraggableContextProvider
value={{
isDragging: false,
dragHandleProps: null,
}}
>
<RecordTableCellContext.Provider
@@ -0,0 +1,2 @@
export const RECORD_TABLE_NO_RECORD_GROUP_DROPPABLE_ID =
'record-table-no-record-group';
@@ -0,0 +1 @@
export const RECORD_TABLE_ROW_DND_TYPE = 'record-table-row';
@@ -16,6 +16,7 @@ export const TABLE_Z_INDEX = {
normalCell: 8,
},
columnGrip: 30,
rowDropLine: 9,
footer: {
default: 8,
stickyColumn: 9,
@@ -1,9 +1,7 @@
import { type DraggableProvidedDragHandleProps } from '@hello-pangea/dnd';
import { createRequiredContext } from '~/utils/createRequiredContext';
export type RecordTableRowDraggableContextValue = {
isDragging: boolean;
dragHandleProps: DraggableProvidedDragHandleProps | null;
};
export const [
@@ -1,7 +0,0 @@
import { useRecordTableBodyDroppableContextOrThrow } from '@/object-record/record-table/record-table-body/contexts/RecordTableBodyDroppableContext';
export const RecordTableBodyDroppablePlaceholder = () => {
const { droppablePlaceholder } = useRecordTableBodyDroppableContextOrThrow();
return droppablePlaceholder;
};
@@ -38,7 +38,6 @@ export const RecordTableBodyLoading = () => {
>
<RecordTableRowDraggableContextProvider
value={{
dragHandleProps: {} as any,
isDragging: false,
}}
>
@@ -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<DragDropItemData>) => {
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<DragDropItemData>) => {
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 (
<DragDropContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<DragDropProvider<DragDropItemData>
sensors={DND_KIT_SENSORS}
plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
{children}
</DragDropContext>
<DragOverlay>
{(source) => <RecordTableRowDragOverlayContent source={source} />}
</DragOverlay>
</DragDropProvider>
);
};
@@ -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 (
<Droppable
droppableId={v4Persistable}
isDropDisabled={isDropDisabled}
mode={'virtual'}
renderClone={(draggableProvided, draggableSnapshot, rubric) => (
<RecordTableBodyVirtualizedDraggableClone
draggableProvided={draggableProvided}
draggableSnapshot={draggableSnapshot}
rubric={rubric}
/>
)}
>
{(provided) => (
<RecordTableBody
ref={provided.innerRef}
// oxlint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
>
<RecordTableBodyDroppableContextProvider
value={{ droppablePlaceholder: provided.placeholder }}
>
{children}
</RecordTableBodyDroppableContextProvider>
</RecordTableBody>
)}
</Droppable>
);
};
@@ -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<DragDropItemData>) => {
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<DragDropItemData>) => {
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 (
<DragDropContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<DragDropProvider<DragDropItemData>
sensors={DND_KIT_SENSORS}
plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
{children}
</DragDropContext>
<DragOverlay>
{(source) => <RecordTableRowDragOverlayContent source={source} />}
</DragOverlay>
</DragDropProvider>
);
};
@@ -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 (
<Droppable
droppableId={recordGroupId}
isDropDisabled={isDropDisabled}
mode={'standard'}
>
{(provided) => (
<RecordTableBody
ref={provided.innerRef}
// oxlint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
data-replay-ignore-mutations="true"
>
<RecordTableBodyDroppableContextProvider
value={{ droppablePlaceholder: provided.placeholder }}
>
{children}
</RecordTableBodyDroppableContextProvider>
</RecordTableBody>
)}
</Droppable>
);
};
@@ -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 (
<RecordTableNoRecordGroupBodyContextProvider>
<RecordTableBodyNoRecordGroupDragDropContextProvider>
<RecordTableBodyNoRecordGroupDroppable>
<RecordTableBody>
<RecordTableNoRecordGroupRows />
<RecordTableCellPortals />
</RecordTableBodyNoRecordGroupDroppable>
</RecordTableBody>
{!isRecordTableInitialLoading && recordTableHasRecords && (
<RecordTableAggregateFooter />
)}
@@ -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}
>
<RecordGroupContext.Provider value={{ recordGroupId }}>
<RecordTableBodyRecordGroupDroppable
recordGroupId={recordGroupId}
>
<RecordTableBody data-replay-ignore-mutations="true">
<RecordTableRecordGroupSection />
<RecordTableRecordGroupRows />
{index === 0 && <RecordTableCellPortals />}
</RecordTableBodyRecordGroupDroppable>
</RecordTableBody>
</RecordGroupContext.Provider>
</RecordTableRecordGroupBodyContextProvider>
))}
@@ -1,13 +0,0 @@
import { type ReactNode } from 'react';
import { createRequiredContext } from '~/utils/createRequiredContext';
export type RecordTableBodyDroppableContextValue = {
droppablePlaceholder: ReactNode;
};
export const [
RecordTableBodyDroppableContextProvider,
useRecordTableBodyDroppableContextOrThrow,
] = createRequiredContext<RecordTableBodyDroppableContextValue>(
'RecordTableBodyDroppableContext',
);
@@ -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 (
<RecordTableCellStyleWrapper
// oxlint-disable-next-line react/jsx-props-no-spreading
{...dragHandleProps}
data-select-disable
hasRightBorder={false}
hasBottomBorder={false}
widthClassName={RECORD_TABLE_COLUMN_DRAG_AND_DROP_WIDTH_CLASS_NAME}
>
<StyledContainer>
<StyledContainer ref={sortableHandleRef}>
<StyledIconWrapper className="icon" isDragging={isDragging}>
<IconListViewGrip />
</StyledIconWrapper>
@@ -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<DraggableProvidedDragHandleProps> | 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),
@@ -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<DraggableProvidedDragHandleProps> | 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}
@@ -12,7 +12,6 @@ export const recordTableRowContextValue: RecordTableRowContextValue = {
export const recordTableRowDraggableContextValue: RecordTableRowDraggableContextValue =
{
dragHandleProps: {} as any,
isDragging: false,
};
@@ -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<typeof DragDropProvider<DragDropItemData>>['onDragStart']
>
>[0];
type DragMovePayload = Parameters<
NonNullable<
ComponentProps<typeof DragDropProvider<DragDropItemData>>['onDragMove']
>
>[0];
type DragEndPayload = Parameters<
NonNullable<
ComponentProps<typeof DragDropProvider<DragDropItemData>>['onDragEnd']
>
>[0];
type DragStartPayload = DragDropProviderDragStartEvent<DragDropItemData>;
type DragMovePayload = DragDropProviderDragMoveEvent<DragDropItemData>;
type DragEndPayload = DragDropProviderDragEndEvent<DragDropItemData>;
export type RecordTableHeaderDndKitContextValues = {
activeDropTargetIndex: number | null;
@@ -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 = ({
<DragDropItemDndContext.Provider value={contextValues}>
<DragDropProvider<DragDropItemData>
sensors={DND_KIT_SENSORS}
plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION}
onDragStart={handlers.onDragStart}
onDragMove={handlers.onDragMove}
onDragEnd={handlers.onDragEnd}
@@ -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 (
<Draggable
key={recordId}
draggableId={recordId}
index={draggableIndex}
isDragDisabled={isDragDisabled}
<RecordTableTr
recordId={recordId}
focusIndex={focusIndex}
ref={ref}
className={className}
style={{
background: isDragging ? theme.background.transparent.light : undefined,
borderColor: isDragging
? `${theme.border.color.medium}`
: 'transparent',
opacity: isSecondaryDragged ? 0.3 : undefined,
}}
isDragging={isDragging}
data-testid={`row-id-${recordId}`}
data-selectable-id={recordId}
onClick={onClick}
>
{(draggableProvided, draggableSnapshot) => (
<>
<RecordTableTr
recordId={recordId}
focusIndex={focusIndex}
ref={draggableProvided.innerRef}
className={className}
// oxlint-disable-next-line react/jsx-props-no-spreading
{...draggableProvided.draggableProps}
style={{
...draggableProvided.draggableProps.style,
background: draggableSnapshot.isDragging
? theme.background.transparent.light
: undefined,
borderColor: draggableSnapshot.isDragging
? `${theme.border.color.medium}`
: 'transparent',
opacity: isSecondaryDragged ? 0.3 : undefined,
}}
isDragging={draggableSnapshot.isDragging}
data-testid={`row-id-${recordId}`}
data-selectable-id={recordId}
onClick={onClick}
>
<RecordTableRowDraggableContextProvider
value={{
isDragging: draggableSnapshot.isDragging,
dragHandleProps: draggableProvided.dragHandleProps,
}}
>
{children}
<RecordTableRowMultiDragPreview />
</RecordTableRowDraggableContextProvider>
</RecordTableTr>
</>
)}
</Draggable>
<DragDropItemSortableHandleRefContext.Provider value={handleRef}>
<RecordTableRowDraggableContextProvider value={{ isDragging }}>
{children}
<RecordTableRowMultiDragPreview />
</RecordTableRowDraggableContextProvider>
</DragDropItemSortableHandleRefContext.Provider>
{isDropTarget && !isDragSource && <StyledRowDropLine />}
</RecordTableTr>
);
};
@@ -10,6 +10,8 @@ const StyledTr = styled.div<{
display: flex;
flex-direction: row;
position: relative;
&[data-focused='true'],
&[data-active='true'] {
div.table-cell,
@@ -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<string, string> =
@@ -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 (
<StyledRowDraggableCloneCSSBridge style={columnWidthStyles}>
<RecordTableTr
recordId={recordId}
focusIndex={realIndex}
ref={draggableProvided.innerRef}
// oxlint-disable-next-line react/jsx-props-no-spreading
{...draggableProvided.draggableProps}
style={{
...draggableProvided.draggableProps.style,
background: draggableSnapshot.isDragging
? themeCssVariables.background.transparent.light
: undefined,
borderColor: draggableSnapshot.isDragging
? themeCssVariables.border.color.medium
: 'transparent',
opacity: isSecondaryDragged ? 0.3 : undefined,
}}
isDragging={draggableSnapshot.isDragging}
data-testid={`row-id-${recordId}`}
data-selectable-id={recordId}
onClick={() => {}}
<StyledRowDragOverlayCSSBridge style={columnWidthStyles}>
<StyledRowClipContainer
style={
isDefined(visibleTableWidth)
? { maxWidth: `${visibleTableWidth}px` }
: undefined
}
>
<RecordTableRowDraggableContextProvider
value={{
isDragging: draggableSnapshot.isDragging,
dragHandleProps: draggableProvided.dragHandleProps,
<RecordTableTr
recordId={recordId}
focusIndex={sourceData.focusIndex}
style={{
background: themeCssVariables.background.transparent.light,
borderColor: themeCssVariables.border.color.medium,
}}
isDragging
data-testid={`row-id-${recordId}`}
data-selectable-id={recordId}
onClick={() => {}}
>
<RecordTableCellDragAndDrop />
<RecordTableCellCheckbox />
<RecordTableFieldsCells />
<RecordTablePlusButtonCellPlaceholder />
<RecordTableLastEmptyCell />
<RecordTableRowMultiDragPreview />
</RecordTableRowDraggableContextProvider>
</RecordTableTr>
</StyledRowDraggableCloneCSSBridge>
<RecordTableRowDraggableContextProvider value={{ isDragging: true }}>
<RecordTableCellDragAndDrop />
<RecordTableCellCheckbox />
<RecordTableFieldsCells />
<RecordTablePlusButtonCellPlaceholder />
<RecordTableLastEmptyCell />
</RecordTableRowDraggableContextProvider>
</RecordTableTr>
</StyledRowClipContainer>
<RecordTableRowMultiDragCounterChip />
</StyledRowDragOverlayCSSBridge>
);
};
@@ -25,7 +25,6 @@ export const RecordTableStaticTr = ({
<RecordTableRowDraggableContextProvider
value={{
isDragging: false,
dragHandleProps: null,
}}
>
{children}
@@ -0,0 +1,6 @@
import { type DragDropItemData } from '@/ui/utilities/drag-and-drop/types/DragDropItemData';
export type RecordTableRowDragData = DragDropItemData & {
recordId: string;
focusIndex: number;
};
@@ -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<HTMLDivElement>(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) => {
@@ -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<TabListProps, 'tabs'> & {
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<OnDragStartResponder>(() => {
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<OnDragEndResponder>(
(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 = ({
/>
)}
<NodeDimension onDimensionChange={onContainerWidthChange}>
{isReorderEnabled && onReorder ? (
<DragDropContext
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragUpdate={handleDragUpdate}
>
<StyledContainer className={className}>
<PageLayoutTabListVisibleTabs
visibleTabs={tabsWithIcons}
visibleTabCount={visibleTabCount}
activeTabId={activeTabId}
behaveAsLinks={behaveAsLinks}
<NodeDimension onDimensionChange={handleContainerWidthChange}>
<StyledContainer className={className}>
<PageLayoutTabListVisibleTabs
visibleTabs={tabsWithIcons}
visibleTabCount={visibleTabCount}
activeTabId={activeTabId}
behaveAsLinks={behaveAsLinks}
loading={loading}
onChangeTab={onChangeTab}
onSelectTab={handleSelectTab}
canReorder={canReorderTabs}
widgetDropTargetTabIds={widgetDropTargetTabIds}
firstHiddenTabId={
hasHiddenTabs ? (hiddenTabs[0]?.id ?? null) : null
}
/>
{shouldRenderReorderableDropdown && (
<StyledDropdownContainer>
<PageLayoutTabListReorderableOverflowDropdown
dropdownId={dropdownId}
hiddenTabs={hiddenTabs}
hiddenTabsCount={hiddenTabsCount}
isActiveTabHidden={isActiveTabHidden}
activeTabId={activeTabId || ''}
loading={loading}
onChangeTab={onChangeTab}
onSelectTab={handleSelectTab}
canReorder={canReorderTabs}
widgetDropTargetTabIds={widgetDropTargetTabIds}
onSelect={handleSelectTabFromDropdown}
visibleTabCount={visibleTabCount}
onClose={closeOverflowDropdown}
pageLayoutType={pageLayoutType}
/>
</StyledDropdownContainer>
)}
{shouldRenderReorderableDropdown && (
<StyledDropdownContainer>
<PageLayoutTabListReorderableOverflowDropdown
dropdownId={dropdownId}
hiddenTabs={hiddenTabs}
hiddenTabsCount={hiddenTabsCount}
isActiveTabHidden={isActiveTabHidden}
activeTabId={activeTabId || ''}
loading={loading}
onSelect={handleSelectTabFromDropdown}
visibleTabCount={visibleTabCount}
onClose={closeOverflowDropdown}
pageLayoutType={pageLayoutType}
/>
</StyledDropdownContainer>
)}
{shouldRenderStaticDropdown && (
<StyledDropdownContainer>
<TabListDropdown
dropdownId={dropdownId}
hiddenTabs={hiddenTabs}
overflow={{
hiddenTabsCount,
isActiveTabHidden,
}}
activeTabId={activeTabId || ''}
loading={loading}
onTabSelect={handleSelectTabFromDropdown}
onClose={closeOverflowDropdown}
/>
</StyledDropdownContainer>
)}
{addTabStrategy?.mode === 'direct' && (
<StyledAddButton>
{addTabStrategy?.mode === 'direct' && (
<StyledAddButton>
<TabButton
id="add-tab"
LeftIcon={IconPlus}
title={t`New Tab`}
onClick={() => addTabStrategy.onCreate()}
disableTestId
/>
</StyledAddButton>
)}
{addTabStrategy?.mode === 'dropdown' && (
<StyledAddButton>
<Dropdown
dropdownId={addTabDropdownId}
clickableComponent={
<TabButton
id="add-tab"
LeftIcon={IconPlus}
title={t`New Tab`}
onClick={() => addTabStrategy.onCreate()}
disableTestId
/>
</StyledAddButton>
)}
{addTabStrategy?.mode === 'dropdown' && (
<StyledAddButton>
<Dropdown
}
dropdownComponents={
<PageLayoutTabListNewTabDropdownContent
onCreate={addTabStrategy.onCreate}
dropdownId={addTabDropdownId}
clickableComponent={
<TabButton
id="add-tab"
LeftIcon={IconPlus}
title={t`New Tab`}
disableTestId
/>
}
dropdownComponents={
<PageLayoutTabListNewTabDropdownContent
onCreate={addTabStrategy.onCreate}
dropdownId={addTabDropdownId}
/>
}
dropdownPlacement="bottom-start"
/>
</StyledAddButton>
)}
</StyledContainer>
</DragDropContext>
) : (
<StyledContainer className={className}>
<PageLayoutTabListVisibleTabs
visibleTabs={tabsWithIcons}
visibleTabCount={visibleTabCount}
activeTabId={activeTabId}
behaveAsLinks={behaveAsLinks}
loading={loading}
onChangeTab={onChangeTab}
onSelectTab={handleSelectTab}
canReorder={canReorderTabs}
widgetDropTargetTabIds={widgetDropTargetTabIds}
/>
{shouldRenderStaticDropdown && (
<StyledDropdownContainer>
<TabListDropdown
dropdownId={dropdownId}
hiddenTabs={hiddenTabs}
overflow={{
hiddenTabsCount,
isActiveTabHidden,
}}
activeTabId={activeTabId || ''}
loading={loading}
onTabSelect={handleSelectTabFromDropdown}
onClose={closeOverflowDropdown}
/>
</StyledDropdownContainer>
)}
{addTabStrategy?.mode === 'direct' && (
<StyledAddButton>
<TabButton
id="add-tab"
LeftIcon={IconPlus}
title={t`New Tab`}
onClick={() => addTabStrategy.onCreate()}
disableTestId
/>
</StyledAddButton>
)}
{addTabStrategy?.mode === 'dropdown' && (
<StyledAddButton>
<Dropdown
dropdownId={addTabDropdownId}
clickableComponent={
<TabButton
id="add-tab"
LeftIcon={IconPlus}
title={t`New Tab`}
disableTestId
/>
}
dropdownComponents={
<PageLayoutTabListNewTabDropdownContent
onCreate={addTabStrategy.onCreate}
dropdownId={addTabDropdownId}
/>
}
dropdownPlacement="bottom-start"
/>
</StyledAddButton>
)}
</StyledContainer>
)}
}
dropdownPlacement="bottom-start"
/>
</StyledAddButton>
)}
</StyledContainer>
</NodeDimension>
</TabListComponentInstanceContext.Provider>
);
@@ -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 (
<Droppable droppableId={PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.MORE_BUTTON}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
// oxlint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
>
<StyledTabMoreButtonWrapper isDraggingOver={snapshot.isDraggingOver}>
<TabMoreButton
hiddenTabsCount={hiddenTabsCount}
active={isActiveTabHidden}
/>
</StyledTabMoreButtonWrapper>
</div>
)}
</Droppable>
<div ref={ref}>
<StyledTabMoreButtonWrapper isDraggingOver={isDropTarget}>
<TabMoreButton
hiddenTabsCount={hiddenTabsCount}
active={isActiveTabHidden}
/>
</StyledTabMoreButtonWrapper>
</div>
);
};
@@ -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={
<DropdownContent widthInPixels={GenericDropdownContentWidth.Medium}>
<Droppable
droppableId={PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.OVERFLOW_TABS}
renderClone={(
provided: DraggableProvided,
_snapshot: DraggableStateSnapshot,
rubric: DraggableRubric,
) => {
const overflowIndex = rubric.source.index - visibleTabCount;
const tab = hiddenTabs[overflowIndex];
<DropdownMenuItemsContainer>
{hiddenTabs.map((tab, index) => {
const disabled = tab.disabled ?? loading;
const tabDragData: PageLayoutTabDragData = {
type: 'tab',
tabId: tab.id,
};
return (
<PageLayoutTabRenderClone
provided={provided}
tab={tab}
activeTabId={activeTabId}
/>
);
}}
>
{(provided) => (
<DropdownMenuItemsContainer>
<div
ref={provided.innerRef}
// oxlint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
<DragDropItemSortableCell
key={tab.id}
id={tab.id}
index={visibleTabCount + index}
group={PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.OVERFLOW_TABS}
data={tabDragData}
type={PAGE_LAYOUT_TAB_DND_TYPE}
accept={PAGE_LAYOUT_TAB_DND_TYPE}
disabled={disabled}
hasTransition={false}
dropLine="horizontal"
>
{hiddenTabs.map((tab, index) => {
const globalIndex = visibleTabCount + index;
const disabled = tab.disabled ?? loading;
return (
<Draggable
key={tab.id}
draggableId={tab.id}
index={globalIndex}
isDragDisabled={disabled}
>
{(draggableProvided, draggableSnapshot) => (
<StyledOverflowDropdownListDraggableWrapper
ref={draggableProvided.innerRef}
// oxlint-disable-next-line react/jsx-props-no-spreading
{...draggableProvided.draggableProps}
// oxlint-disable-next-line react/jsx-props-no-spreading
{...draggableProvided.dragHandleProps}
style={{
...draggableProvided.draggableProps.style,
position: 'relative',
left: 'auto',
top: 'auto',
cursor: draggableSnapshot.isDragging
? 'grabbing'
: 'grab',
background: draggableSnapshot.isDragging
? theme.background.transparent.light
: 'none',
width: 50,
overflow: 'visible',
}}
>
<div
style={{
minWidth:
GenericDropdownContentWidth.Medium -
theme.spacingMultiplicator * 2,
}}
>
<PageLayoutTabMenuItemSelectAvatar
tab={tab}
selected={tab.id === activeTabId}
onClick={
draggableSnapshot.isDragging
? undefined
: () => handleTabSelect(tab.id)
}
disabled={disabled}
showEditButton={shouldShowEditButton}
onEditClick={handleEditClick}
/>
</div>
</StyledOverflowDropdownListDraggableWrapper>
)}
</Draggable>
);
})}
<div>{provided.placeholder}</div>
</div>
</DropdownMenuItemsContainer>
)}
</Droppable>
<StyledOverflowMenuItemWrapper>
<PageLayoutTabMenuItemSelectAvatar
tab={tab}
selected={tab.id === activeTabId}
onClick={() => handleTabSelect(tab.id)}
disabled={disabled}
showEditButton={shouldShowEditButton}
onEditClick={handleEditClick}
/>
</StyledOverflowMenuItemWrapper>
</DragDropItemSortableCell>
);
})}
<StyledOverflowEndDropZone
id={`${PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.OVERFLOW_TABS}-end`}
accept={PAGE_LAYOUT_TAB_DND_TYPE}
data={OVERFLOW_END_DROP_DATA}
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
@@ -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 = (
<Draggable draggableId={tab.id} index={index} isDragDisabled={disabled}>
{(draggableProvided, draggableSnapshot) => (
<StyledTabContainer
ref={draggableProvided.innerRef}
// oxlint-disable-next-line react/jsx-props-no-spreading
{...draggableProvided.draggableProps}
// oxlint-disable-next-line react/jsx-props-no-spreading
{...draggableProvided.dragHandleProps}
onClick={draggableSnapshot.isDragging ? undefined : onSelect}
active={isActive}
disabled={disabled}
style={{
...draggableProvided.draggableProps.style,
cursor: draggableSnapshot.isDragging ? 'grabbing' : 'pointer',
}}
>
<StyledTabContentWrapper isBeingEdited={isSettingsOpenForThisTab}>
<TabContent
id={tab.id}
active={isActive}
disabled={disabled}
LeftIcon={tab.Icon}
title={tab.title}
logo={tab.logo}
pill={tab.pill}
/>
</StyledTabContentWrapper>
</StyledTabContainer>
)}
</Draggable>
<DragDropItemSortableCell
id={tab.id}
index={index}
group={group}
data={tabDragData}
type={PAGE_LAYOUT_TAB_DND_TYPE}
accept={PAGE_LAYOUT_TAB_DND_TYPE}
disabled={disabled}
fill
hasTransition={false}
dropLine={dropLineOrientation}
>
<StyledTabContainer
onClick={onSelect}
active={isActive}
disabled={disabled}
>
<StyledTabContentWrapper isBeingEdited={isSettingsOpenForThisTab}>
<TabContent
id={tab.id}
active={isActive}
disabled={disabled}
LeftIcon={tab.Icon}
title={tab.title}
logo={tab.logo}
pill={tab.pill}
/>
</StyledTabContentWrapper>
</StyledTabContainer>
</DragDropItemSortableCell>
);
if (!isWidgetDropTarget) {
@@ -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<string>;
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 (
<Droppable
droppableId={PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.VISIBLE_TABS}
direction="horizontal"
renderClone={(
provided: DraggableProvided,
_snapshot: DraggableStateSnapshot,
rubric: DraggableRubric,
) => {
const tab = visibleTabs[rubric.source.index];
const endDropData: PageLayoutTabListEndDropData = {
type: 'tab-list-end',
beforeTabId: firstHiddenTabId,
};
return (
<PageLayoutTabRenderClone
provided={provided}
tab={tab}
activeTabId={activeTabId}
/>
);
}}
>
{(provided) => (
<StyledTabContainer
ref={provided.innerRef}
// oxlint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
>
{visibleTabs.slice(0, visibleTabCount).map((tab, index) => (
<PageLayoutTabListReorderableTab
key={tab.id}
tab={tab}
index={index}
isActive={tab.id === activeTabId}
disabled={tab.disabled ?? loading}
isWidgetDropTarget={widgetDropTargetTabIds.has(tab.id)}
onSelect={() => onSelectTab(tab.id)}
/>
))}
{provided.placeholder}
</StyledTabContainer>
)}
</Droppable>
return (
<StyledTabContainer>
{visibleTabs.slice(0, visibleTabCount).map((tab, index) => (
<PageLayoutTabListReorderableTab
key={tab.id}
tab={tab}
index={index}
group={PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.VISIBLE_TABS}
isActive={tab.id === activeTabId}
disabled={tab.disabled ?? loading}
isWidgetDropTarget={widgetDropTargetTabIds.has(tab.id)}
onSelect={() => onSelectTab(tab.id)}
/>
))}
<StyledEndDropZone
id={`${PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.VISIBLE_TABS}-end`}
accept={PAGE_LAYOUT_TAB_DND_TYPE}
data={endDropData}
dropLine="vertical"
/>
</StyledTabContainer>
);
}
@@ -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 (
<StyledDraggableWrapper
ref={provided.innerRef}
// oxlint-disable-next-line react/jsx-props-no-spreading
{...provided.draggableProps}
// oxlint-disable-next-line react/jsx-props-no-spreading
{...provided.dragHandleProps}
style={{
...provided.draggableProps.style,
cursor: 'grabbing',
}}
>
<StyledTabContainer active={tab.id === activeTabId} disabled={false}>
<TabContent
id={tab.id}
active={false}
disabled={false}
LeftIcon={tab.Icon}
title={tab.title}
logo={tab.logo}
pill={tab.pill}
/>
</StyledTabContainer>
</StyledDraggableWrapper>
);
} else {
return (
<StyledDraggableWrapper
id={'clone-drag-wrapper'}
ref={provided.innerRef}
// oxlint-disable-next-line react/jsx-props-no-spreading
{...provided.draggableProps}
// oxlint-disable-next-line react/jsx-props-no-spreading
{...provided.dragHandleProps}
style={{
...provided.draggableProps.style,
cursor: 'grabbing',
}}
>
<div
style={{
minWidth:
GenericDropdownContentWidth.Medium -
theme.spacingMultiplicator * 2,
}}
>
<MenuItemSelectAvatar
text={tab.title}
avatar={<TabAvatar tab={tab} />}
selected={tab.id === activeTabId}
onClick={undefined}
disabled
/>
</div>
</StyledDraggableWrapper>
);
}
};
@@ -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}
/>
)}
@@ -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 (
<StyledVerticalListContainer
variant={variant}
shouldUseWhiteBackground={!isInPinnedTab || isMobile}
>
{widgets.map((widget, index) => (
<PageLayoutWidgetSortableItem
key={widget.id}
widgetId={widget.id}
tabId={tabId}
index={index}
>
<WidgetRenderer widget={widget} />
</PageLayoutWidgetSortableItem>
))}
<StyledEndDropZone ref={endDropRef}>
{isEndDropTarget && <PageLayoutWidgetDropLine />}
{widgets.map((widget, index) => {
const widgetDragData: PageLayoutWidgetDragData = {
type: 'widget',
widgetId: widget.id,
tabId,
index,
};
return (
<DragDropItemSortableCell
key={widget.id}
id={widget.id}
index={index}
group={tabId}
data={widgetDragData}
type={PAGE_LAYOUT_WIDGET_DND_TYPE}
accept={PAGE_LAYOUT_WIDGET_DND_TYPE}
hasTransition={false}
highlightWhileDragging={true}
dropLine="horizontal"
>
<WidgetRenderer widget={widget} />
</DragDropItemSortableCell>
);
})}
<StyledEndDropZone
id={`page-layout-widget-list-${tabId}`}
accept={PAGE_LAYOUT_WIDGET_DND_TYPE}
data={endDropData}
>
{trailingElement}
</StyledEndDropZone>
</StyledVerticalListContainer>
@@ -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<PageLayoutTab[]>(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"
/>
<PageLayoutTabList
tabs={sortedTabs}
componentInstanceId="page-layout-tab-list-story"
behaveAsLinks={false}
loading={false}
addTabStrategy={
isReorderEnabled
? { mode: 'direct', onCreate: handleAddTab }
: undefined
}
isReorderEnabled={isReorderEnabled}
onReorder={isReorderEnabled ? handleReorder : undefined}
pageLayoutType={PageLayoutType.DASHBOARD}
/>
<PageLayoutWidgetDndProvider>
<PageLayoutTabList
tabs={sortedTabs}
componentInstanceId="page-layout-tab-list-story"
behaveAsLinks={false}
loading={false}
addTabStrategy={
isReorderEnabled
? { mode: 'direct', onCreate: handleAddTab }
: undefined
}
isReorderEnabled={isReorderEnabled}
pageLayoutType={PageLayoutType.DASHBOARD}
/>
</PageLayoutWidgetDndProvider>
</StyledContainer>
);
};
@@ -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 (
<StyledDropTarget ref={ref} isActive={isDropTarget}>
<StyledDropTarget
ref={ref}
isActive={isDropTarget || pageLayoutGridDragHoveredTabId === tabId}
{...{ [PAGE_LAYOUT_TAB_DROP_TARGET_DATA_ATTRIBUTE]: tabId }}
>
{children}
</StyledDropTarget>
);
@@ -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 (
<DragDropProvider<PageLayoutWidgetDndData>
sensors={DND_KIT_SENSORS}
plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION}
onDragStart={handlers.onDragStart}
onDragEnd={handlers.onDragEnd}
>
@@ -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 = () => (
<StyledDropLineContainer>
<StyledDropLine />
</StyledDropLineContainer>
);
@@ -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 (
<StyledSortableRoot
ref={ref}
isDragging={isDragging}
onDragStart={preventNativeDragStart}
>
{isDropTarget && <PageLayoutWidgetDropLine />}
{children}
</StyledSortableRoot>
);
};
@@ -0,0 +1 @@
export const PAGE_LAYOUT_TAB_DND_TYPE = 'page-layout-tab';
@@ -0,0 +1,2 @@
export const PAGE_LAYOUT_TAB_DROP_TARGET_DATA_ATTRIBUTE =
'data-page-layout-tab-drop-target-id';
@@ -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;
@@ -0,0 +1 @@
export const PAGE_LAYOUT_WIDGET_DND_TYPE = 'page-layout-widget';
@@ -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<typeof makeTab>[]): DraftPageLayout => ({
id: 'test-layout',
name: 'Test Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: null,
tabs,
});
describe('useCanMovePageLayoutWidgetDown', () => {
const getWrapper =
(store = createStore()) =>
@@ -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<typeof makeTab>[]): DraftPageLayout => ({
id: 'test-layout',
name: 'Test Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: null,
tabs,
});
describe('useCanMovePageLayoutWidgetUp', () => {
const getWrapper =
(store = createStore()) =>
@@ -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<typeof makeTab>[]): DraftPageLayout => ({
id: 'test-layout',
name: 'Test Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: null,
tabs,
});
describe('useInsertCreatedWidgetAtContext', () => {
const getWrapper =
(store = createStore()) =>
@@ -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<typeof makeTab>[]): DraftPageLayout => ({
id: 'test-layout',
name: 'Test Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: null,
tabs,
});
describe('useMovePageLayoutWidgetDown', () => {
const getWrapper =
(store = createStore()) =>
@@ -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<typeof makeTab>[]): DraftPageLayout => ({
id: 'test-layout',
name: 'Test Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: null,
tabs,
});
describe('useMovePageLayoutWidgetUp', () => {
const getWrapper =
(store = createStore()) =>
@@ -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],
@@ -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],
@@ -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<HTMLElement>(
`[${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,
};
};
@@ -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<PageLayoutWidgetDndData>;
type DragStartEvent = Parameters<
NonNullable<ComponentProps<Provider>['onDragStart']>
>[0];
type DragEndEvent = Parameters<
NonNullable<ComponentProps<Provider>['onDragEnd']>
>[0];
type DragStartEvent = DragDropProviderDragStartEvent<PageLayoutWidgetDndData>;
type DragEndEvent = DragDropProviderDragEndEvent<PageLayoutWidgetDndData>;
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],
@@ -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 };
};
@@ -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 };
};
@@ -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<string | null>({
key: 'pageLayoutGridDragHoveredTabIdComponentState',
defaultValue: null,
componentInstanceContext: PageLayoutComponentInstanceContext,
});
@@ -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<boolean>({
key: 'pageLayoutShouldIgnoreNextGridLayoutChangeComponentState',
defaultValue: false,
componentInstanceContext: PageLayoutComponentInstanceContext,
});
@@ -1,10 +0,0 @@
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
import { PageLayoutComponentInstanceContext } from './contexts/PageLayoutComponentInstanceContext';
export const pageLayoutTabListCurrentDragDroppableIdComponentState =
createAtomComponentState<string | undefined>({
key: 'pageLayoutTabListCurrentDragDroppableIdComponentState',
defaultValue: undefined,
componentInstanceContext: PageLayoutComponentInstanceContext,
});
@@ -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<PageLayoutTab>,
) => ({
id,
applicationId: '',
@@ -46,6 +48,7 @@ export const makeTab = (
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
...overrides,
});
export const makeDraft = (
@@ -0,0 +1,4 @@
export type PageLayoutTabDragData = {
type: 'tab';
tabId: string;
};
@@ -0,0 +1,5 @@
// beforeTabId null means append after the last tab.
export type PageLayoutTabListEndDropData = {
type: 'tab-list-end';
beforeTabId: string | null;
};
@@ -0,0 +1,3 @@
export type PageLayoutTabMoreButtonDropData = {
type: 'tab-more-button';
};
@@ -0,0 +1,4 @@
export type PageLayoutTabWidgetDropData = {
type: 'tab-widget-drop';
tabId: string;
};
@@ -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;
@@ -0,0 +1,6 @@
export type PageLayoutWidgetDragData = {
type: 'widget';
widgetId: string;
tabId: string;
index: number;
};
@@ -0,0 +1,4 @@
export type PageLayoutWidgetListDropData = {
type: 'widget-list';
tabId: string;
};
@@ -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,
});
});
});
@@ -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> = {}): 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);
});
@@ -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);
});
});
@@ -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);
});
});
@@ -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', () => {
@@ -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 })),
};
};
@@ -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;
@@ -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;
@@ -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;
}),
};
};
@@ -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;
}),
};
};

Some files were not shown because too many files have changed in this diff Show More