mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-28 16:50:40 +01:00
Compare commits
2 Commits
refactor/q
...
feat/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e751e44478 | ||
|
|
255b280037 |
@@ -0,0 +1,23 @@
|
||||
.preview {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 320px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--bg-robin-500);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-ink-400, #0b0c0e);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 40%);
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.grip {
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { GripVertical } from '@signozhq/icons';
|
||||
|
||||
import type { GridItem } from '../../../utils';
|
||||
import styles from './PanelDragPreview.module.scss';
|
||||
|
||||
interface PanelDragPreviewProps {
|
||||
item: GridItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight preview shown in the DragOverlay while a panel is being dragged
|
||||
* across sections. Title-only (no chart) so the overlay stays cheap and never
|
||||
* mounts a query-bound panel.
|
||||
*/
|
||||
function PanelDragPreview({ item }: PanelDragPreviewProps): JSX.Element {
|
||||
const title = item.panel?.spec?.display?.name || 'Panel';
|
||||
return (
|
||||
<div className={styles.preview}>
|
||||
<GripVertical size={14} className={styles.grip} />
|
||||
<span className={styles.title}>{title}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelDragPreview;
|
||||
@@ -8,6 +8,14 @@
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
// A panel is being dragged over this section — highlight it as the drop target.
|
||||
.dropTarget {
|
||||
border-radius: 4px;
|
||||
outline: 1px dashed var(--bg-robin-500);
|
||||
outline-offset: 2px;
|
||||
background: color-mix(in srgb, var(--bg-robin-500) 6%, transparent);
|
||||
}
|
||||
|
||||
.deleteModal :global(.ant-modal-confirm-body) {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import cx from 'classnames';
|
||||
|
||||
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
|
||||
import DisabledControlTooltip from '../../../components/DisabledControlTooltip/DisabledControlTooltip';
|
||||
@@ -9,6 +11,7 @@ import { useCreatePanel } from '../../../hooks/useCreatePanel';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import PanelTypeSelectionModal from '../../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import { sectionDropId } from '../hooks/crossSectionDrag';
|
||||
import { useCloneSection } from '../hooks/useCloneSection';
|
||||
import { useDeleteSection } from '../hooks/useDeleteSection';
|
||||
import { useRenameSection } from '../hooks/useRenameSection';
|
||||
@@ -27,9 +30,16 @@ interface SectionProps {
|
||||
sections?: DashboardSection[];
|
||||
/** Provided by SortableSection in sectioned mode; absent for untitled/free-flow. */
|
||||
dragHandle?: SectionDragHandle;
|
||||
/** Register this section as a cross-section panel drop target. */
|
||||
enablePanelDrag?: boolean;
|
||||
}
|
||||
|
||||
function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
function Section({
|
||||
section,
|
||||
sections,
|
||||
dragHandle,
|
||||
enablePanelDrag = false,
|
||||
}: SectionProps): JSX.Element {
|
||||
const canEditDashboard = useDashboardStore((s) => s.canEditDashboard);
|
||||
const isLocked = useDashboardStore((s) => s.isLocked);
|
||||
const {
|
||||
@@ -66,14 +76,30 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
|
||||
const cloneSection = useCloneSection();
|
||||
|
||||
const sectionRef = useRef<HTMLDivElement>(null);
|
||||
const sectionRef = useRef<HTMLDivElement | null>(null);
|
||||
useScrollIntoView(section.id, sectionRef);
|
||||
|
||||
// Cross-section panel drop target. `setSectionRef` fans the node out to both
|
||||
// the scroll-into-view ref and dnd-kit's droppable ref; `isOver` highlights
|
||||
// the section while a panel hovers it.
|
||||
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
|
||||
id: sectionDropId(section.layoutIndex),
|
||||
disabled: !enablePanelDrag,
|
||||
});
|
||||
const setSectionRef = useCallback(
|
||||
(node: HTMLDivElement | null): void => {
|
||||
sectionRef.current = node;
|
||||
setDroppableRef(node);
|
||||
},
|
||||
[setDroppableRef],
|
||||
);
|
||||
|
||||
const grid = (
|
||||
<SectionGrid
|
||||
items={section.items}
|
||||
layoutIndex={section.layoutIndex}
|
||||
sections={sections}
|
||||
enablePanelDrag={enablePanelDrag}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -81,7 +107,8 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
// Untitled section — just the grid, no header chrome.
|
||||
return (
|
||||
<div
|
||||
ref={sectionRef}
|
||||
ref={setSectionRef}
|
||||
className={cx(isOver && styles.dropTarget)}
|
||||
data-testid={`dashboard-section-${section.id}`}
|
||||
data-section-layout-index={section.layoutIndex}
|
||||
>
|
||||
@@ -92,8 +119,8 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sectionRef}
|
||||
className={styles.section}
|
||||
ref={setSectionRef}
|
||||
className={cx(styles.section, isOver && styles.dropTarget)}
|
||||
data-testid={`dashboard-section-${section.id}`}
|
||||
data-section-layout-index={section.layoutIndex}
|
||||
>
|
||||
|
||||
@@ -23,6 +23,47 @@
|
||||
|
||||
// Lazy-load observer boundary (SectionGridItem); fills the grid cell.
|
||||
.panelWrapper {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// Cross-section drag grip: a small handle at the panel's top-left, revealed on
|
||||
// hover. `panel-no-drag` keeps react-grid-layout from treating it as an
|
||||
// intra-section move handle, so it exclusively starts the dnd-kit cross-section
|
||||
// drag.
|
||||
.panelDragHandle {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
z-index: 3;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-ink-400, #0b0c0e);
|
||||
color: var(--l2-foreground);
|
||||
opacity: 0;
|
||||
cursor: grab;
|
||||
transition: opacity 100ms ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.panelWrapper:hover .panelDragHandle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// The original panel is dimmed while its DragOverlay preview follows the pointer.
|
||||
.panelDragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
@@ -14,12 +14,15 @@ interface SectionGridProps {
|
||||
layoutIndex: number;
|
||||
/** All sections — layout context for the panel menu's move/delete actions. */
|
||||
sections?: DashboardSection[];
|
||||
/** Enable the cross-section drag grip on each panel (sectioned edit mode). */
|
||||
enablePanelDrag?: boolean;
|
||||
}
|
||||
|
||||
function SectionGrid({
|
||||
items,
|
||||
layoutIndex,
|
||||
sections,
|
||||
enablePanelDrag = false,
|
||||
}: SectionGridProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const rglLayout = useMemo<Layout[]>(
|
||||
@@ -61,6 +64,8 @@ function SectionGrid({
|
||||
<SectionGridItem
|
||||
panel={item.panel}
|
||||
panelId={item.id}
|
||||
layoutIndex={layoutIndex}
|
||||
enablePanelDrag={enablePanelDrag}
|
||||
panelActions={
|
||||
isEditable
|
||||
? {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useRef } from 'react';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { GripVertical } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
|
||||
|
||||
import Panel, { type PanelActionsConfig } from '../../Panel/Panel';
|
||||
import { panelDraggableId } from '../hooks/crossSectionDrag';
|
||||
import { useScrollIntoView } from '../hooks/useScrollIntoView';
|
||||
import styles from './SectionGrid.module.scss';
|
||||
|
||||
@@ -13,17 +17,25 @@ const VIEWPORT_OBSERVER_OPTIONS: IntersectionObserverInit = {
|
||||
interface SectionGridItemProps {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
/** This panel's section, carried on the drag so the drop knows its origin. */
|
||||
layoutIndex: number;
|
||||
/** Show the cross-section drag grip (sectioned + editable boards only). */
|
||||
enablePanelDrag: boolean;
|
||||
panelActions?: PanelActionsConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy-loads a single panel: watches its own viewport intersection (latched) and
|
||||
* passes it to the presentational Panel as `isVisible`, so a board of many panels
|
||||
* only fetches what's on screen.
|
||||
* only fetches what's on screen. In sectioned edit mode it also exposes a grip
|
||||
* that starts a cross-section drag (dnd-kit); the grip is `panel-no-drag` so
|
||||
* react-grid-layout never treats it as an intra-section move handle.
|
||||
*/
|
||||
function SectionGridItem({
|
||||
panel,
|
||||
panelId,
|
||||
layoutIndex,
|
||||
enablePanelDrag,
|
||||
panelActions,
|
||||
}: SectionGridItemProps): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -34,8 +46,31 @@ function SectionGridItem({
|
||||
);
|
||||
useScrollIntoView(panelId, containerRef);
|
||||
|
||||
const { setNodeRef, listeners, attributes, isDragging } = useDraggable({
|
||||
id: panelDraggableId(panelId),
|
||||
data: { panelId, fromLayoutIndex: layoutIndex },
|
||||
disabled: !enablePanelDrag,
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={styles.panelWrapper}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cx(styles.panelWrapper, isDragging && styles.panelDragging)}
|
||||
>
|
||||
{enablePanelDrag && (
|
||||
<button
|
||||
type="button"
|
||||
ref={setNodeRef}
|
||||
className={cx('panel-no-drag', styles.panelDragHandle)}
|
||||
aria-label="Drag panel to another section"
|
||||
title="Drag to move to another section"
|
||||
data-testid={`panel-drag-handle-${panelId}`}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical size={12} />
|
||||
</button>
|
||||
)}
|
||||
<Panel
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { useMemo } from 'react';
|
||||
import { closestCenter, DndContext, DragOverlay } from '@dnd-kit/core';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
closestCenter,
|
||||
type CollisionDetection,
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
DragOverlay,
|
||||
type DragStartEvent,
|
||||
type Modifier,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
restrictToParentElement,
|
||||
restrictToVerticalAxis,
|
||||
@@ -12,7 +20,9 @@ import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.sche
|
||||
|
||||
import type { DashboardSection } from '../../utils';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { usePanelDragMove } from './hooks/usePanelDragMove';
|
||||
import { useSectionDragReorder } from './hooks/useSectionDragReorder';
|
||||
import PanelDragPreview from './PanelDragPreview/PanelDragPreview';
|
||||
import Section from './Section/Section';
|
||||
import SectionDragPreview from './SectionDragPreview/SectionDragPreview';
|
||||
import SortableSection from './SortableSection';
|
||||
@@ -22,6 +32,21 @@ interface SectionListProps {
|
||||
layouts: DashboardtypesLayoutDTO[] | undefined | null;
|
||||
}
|
||||
|
||||
// Section reorder is confined to a vertical list within its parent; a panel
|
||||
// cross-section drag moves freely, so it runs with no modifiers.
|
||||
const SECTION_MODIFIERS: Modifier[] = [
|
||||
restrictToVerticalAxis,
|
||||
restrictToParentElement,
|
||||
];
|
||||
const NO_MODIFIERS: Modifier[] = [];
|
||||
|
||||
/**
|
||||
* Hosts a single dnd-kit DndContext for two drag kinds: reordering sections
|
||||
* (sortable list) and moving a panel between sections (a panel grip dragged onto
|
||||
* another section's drop zone). Each event is routed by its namespaced id —
|
||||
* panel handlers get first refusal, section-reorder handles the rest — and the
|
||||
* modifiers/collision detection switch to match the active kind.
|
||||
*/
|
||||
function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
|
||||
@@ -29,11 +54,20 @@ function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
|
||||
sensors,
|
||||
orderedSections,
|
||||
activeSection,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragCancel,
|
||||
onDragStart: onSectionDragStart,
|
||||
onDragEnd: onSectionDragEnd,
|
||||
onDragCancel: onSectionDragCancel,
|
||||
} = useSectionDragReorder({ sections, layouts });
|
||||
|
||||
const {
|
||||
activePanel,
|
||||
isDraggingPanel,
|
||||
panelCollisionDetection,
|
||||
handleDragStart: onPanelDragStart,
|
||||
handleDragEnd: onPanelDragEnd,
|
||||
handleDragCancel: onPanelDragCancel,
|
||||
} = usePanelDragMove({ sections });
|
||||
|
||||
// Only titled sections participate in reordering; untitled (free-flow)
|
||||
// blocks render in place without a drag handle.
|
||||
const sortableIds = useMemo(
|
||||
@@ -41,6 +75,36 @@ function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
|
||||
[orderedSections],
|
||||
);
|
||||
|
||||
// Cross-section dragging only makes sense with somewhere to move to.
|
||||
const enablePanelDrag = orderedSections.length > 1;
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent): void => {
|
||||
if (!onPanelDragStart(event)) {
|
||||
onSectionDragStart(event);
|
||||
}
|
||||
},
|
||||
[onPanelDragStart, onSectionDragStart],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent): void => {
|
||||
if (!onPanelDragEnd(event)) {
|
||||
onSectionDragEnd(event);
|
||||
}
|
||||
},
|
||||
[onPanelDragEnd, onSectionDragEnd],
|
||||
);
|
||||
|
||||
const handleDragCancel = useCallback((): void => {
|
||||
onPanelDragCancel();
|
||||
onSectionDragCancel();
|
||||
}, [onPanelDragCancel, onSectionDragCancel]);
|
||||
|
||||
const collisionDetection: CollisionDetection = isDraggingPanel
|
||||
? panelCollisionDetection
|
||||
: closestCenter;
|
||||
|
||||
if (!isEditable) {
|
||||
return (
|
||||
<>
|
||||
@@ -54,25 +118,39 @@ function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragCancel={onDragCancel}
|
||||
collisionDetection={collisionDetection}
|
||||
modifiers={isDraggingPanel ? NO_MODIFIERS : SECTION_MODIFIERS}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
|
||||
{orderedSections.map((section) =>
|
||||
section.title ? (
|
||||
<SortableSection key={section.id} section={section} sections={sections} />
|
||||
<SortableSection
|
||||
key={section.id}
|
||||
section={section}
|
||||
sections={sections}
|
||||
enablePanelDrag={enablePanelDrag}
|
||||
/>
|
||||
) : (
|
||||
<Section key={section.id} section={section} sections={sections} />
|
||||
<Section
|
||||
key={section.id}
|
||||
section={section}
|
||||
sections={sections}
|
||||
enablePanelDrag={enablePanelDrag}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</SortableContext>
|
||||
{/* dropAnimation disabled: optimistic reorder already places the section,
|
||||
so animating the overlay back would cause a visible snap/shake. */}
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeSection ? <SectionDragPreview section={activeSection} /> : null}
|
||||
{activeSection ? (
|
||||
<SectionDragPreview section={activeSection} />
|
||||
) : activePanel ? (
|
||||
<PanelDragPreview item={activePanel} />
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
|
||||
@@ -7,11 +7,13 @@ import Section from './Section/Section';
|
||||
interface SortableSectionProps {
|
||||
section: DashboardSection;
|
||||
sections: DashboardSection[];
|
||||
enablePanelDrag?: boolean;
|
||||
}
|
||||
|
||||
function SortableSection({
|
||||
section,
|
||||
sections,
|
||||
enablePanelDrag,
|
||||
}: SortableSectionProps): JSX.Element {
|
||||
const {
|
||||
attributes,
|
||||
@@ -38,6 +40,7 @@ function SortableSection({
|
||||
<Section
|
||||
section={section}
|
||||
sections={sections}
|
||||
enablePanelDrag={enablePanelDrag}
|
||||
dragHandle={{ attributes, listeners, setActivatorNodeRef }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
isPanelDragId,
|
||||
isSectionDropId,
|
||||
layoutIndexFromDropId,
|
||||
panelDraggableId,
|
||||
resolvePanelMove,
|
||||
sectionDropId,
|
||||
} from '../crossSectionDrag';
|
||||
|
||||
describe('crossSectionDrag — id helpers', () => {
|
||||
it('builds and recognises panel drag ids', () => {
|
||||
const id = panelDraggableId('abc');
|
||||
expect(id).toBe('panel:abc');
|
||||
expect(isPanelDragId(id)).toBe(true);
|
||||
expect(isPanelDragId('dropzone:0')).toBe(false);
|
||||
expect(isPanelDragId('sec-abc')).toBe(false);
|
||||
});
|
||||
|
||||
it('builds and parses section drop ids', () => {
|
||||
expect(sectionDropId(3)).toBe('dropzone:3');
|
||||
expect(isSectionDropId('dropzone:3')).toBe(true);
|
||||
expect(isSectionDropId('panel:x')).toBe(false);
|
||||
expect(layoutIndexFromDropId('dropzone:3')).toBe(3);
|
||||
expect(layoutIndexFromDropId('dropzone:0')).toBe(0);
|
||||
expect(layoutIndexFromDropId('panel:x')).toBeNull();
|
||||
expect(layoutIndexFromDropId('dropzone:abc')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePanelMove', () => {
|
||||
const drag = { panelId: 'p1', fromLayoutIndex: 0 };
|
||||
|
||||
it('is a no-op without a drag or a drop target', () => {
|
||||
expect(resolvePanelMove(null, 'dropzone:1')).toBeNull();
|
||||
expect(resolvePanelMove(drag, null)).toBeNull();
|
||||
});
|
||||
|
||||
it('is a no-op when dropped over a non-dropzone', () => {
|
||||
expect(resolvePanelMove(drag, 'sec-abc')).toBeNull();
|
||||
});
|
||||
|
||||
it('is a no-op when dropped back on the same section', () => {
|
||||
expect(resolvePanelMove(drag, 'dropzone:0')).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves a move to a different section', () => {
|
||||
expect(resolvePanelMove(drag, 'dropzone:2')).toStrictEqual({
|
||||
panelId: 'p1',
|
||||
fromLayoutIndex: 0,
|
||||
toLayoutIndex: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import type { DashboardSection } from '../../../../utils';
|
||||
import { usePanelDragMove } from '../usePanelDragMove';
|
||||
|
||||
const movePanel = jest.fn();
|
||||
jest.mock('../../../Panel/hooks/useMovePanelToSection', () => ({
|
||||
useMovePanelToSection: () => movePanel,
|
||||
}));
|
||||
|
||||
const sections: DashboardSection[] = [
|
||||
{
|
||||
id: 'sec-p1',
|
||||
layoutIndex: 0,
|
||||
title: 'A',
|
||||
repeatVariable: undefined,
|
||||
items: [
|
||||
{
|
||||
id: 'p1',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 6,
|
||||
height: 6,
|
||||
panel: { spec: { display: { name: 'Panel 1' } } } as never,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sec-empty-1',
|
||||
layoutIndex: 1,
|
||||
title: 'B',
|
||||
repeatVariable: undefined,
|
||||
items: [],
|
||||
},
|
||||
];
|
||||
|
||||
const startEvent = (id: string, data?: unknown): DragStartEvent =>
|
||||
({ active: { id, data: { current: data } } }) as unknown as DragStartEvent;
|
||||
|
||||
const endEvent = (id: string, overId: string | null): DragEndEvent =>
|
||||
({
|
||||
active: { id, data: { current: undefined } },
|
||||
over: overId ? { id: overId } : null,
|
||||
}) as unknown as DragEndEvent;
|
||||
|
||||
beforeEach(() => movePanel.mockClear());
|
||||
|
||||
describe('usePanelDragMove', () => {
|
||||
it('ignores section-reorder drags (returns false, no active panel)', () => {
|
||||
const { result } = renderHook(() => usePanelDragMove({ sections }));
|
||||
let handled = true;
|
||||
act(() => {
|
||||
handled = result.current.handleDragStart(startEvent('sec-p1'));
|
||||
});
|
||||
expect(handled).toBe(false);
|
||||
expect(result.current.isDraggingPanel).toBe(false);
|
||||
expect(result.current.activePanel).toBeNull();
|
||||
});
|
||||
|
||||
it('tracks the active panel while a grip is dragged', () => {
|
||||
const { result } = renderHook(() => usePanelDragMove({ sections }));
|
||||
act(() => {
|
||||
result.current.handleDragStart(
|
||||
startEvent('panel:p1', { panelId: 'p1', fromLayoutIndex: 0 }),
|
||||
);
|
||||
});
|
||||
expect(result.current.isDraggingPanel).toBe(true);
|
||||
expect(result.current.activePanel?.id).toBe('p1');
|
||||
});
|
||||
|
||||
it('moves the panel to the dropped section', () => {
|
||||
const { result } = renderHook(() => usePanelDragMove({ sections }));
|
||||
act(() => {
|
||||
result.current.handleDragStart(
|
||||
startEvent('panel:p1', { panelId: 'p1', fromLayoutIndex: 0 }),
|
||||
);
|
||||
});
|
||||
let handled = false;
|
||||
act(() => {
|
||||
handled = result.current.handleDragEnd(endEvent('panel:p1', 'dropzone:1'));
|
||||
});
|
||||
expect(handled).toBe(true);
|
||||
expect(movePanel).toHaveBeenCalledWith({
|
||||
panelId: 'p1',
|
||||
fromLayoutIndex: 0,
|
||||
toLayoutIndex: 1,
|
||||
});
|
||||
expect(result.current.isDraggingPanel).toBe(false);
|
||||
});
|
||||
|
||||
it('does not move when dropped back on the same section', () => {
|
||||
const { result } = renderHook(() => usePanelDragMove({ sections }));
|
||||
act(() => {
|
||||
result.current.handleDragStart(
|
||||
startEvent('panel:p1', { panelId: 'p1', fromLayoutIndex: 0 }),
|
||||
);
|
||||
result.current.handleDragEnd(endEvent('panel:p1', 'dropzone:0'));
|
||||
});
|
||||
expect(movePanel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets section-reorder drag-end pass through (returns false)', () => {
|
||||
const { result } = renderHook(() => usePanelDragMove({ sections }));
|
||||
let handled = true;
|
||||
act(() => {
|
||||
handled = result.current.handleDragEnd(endEvent('sec-p1', 'sec-empty-1'));
|
||||
});
|
||||
expect(handled).toBe(false);
|
||||
expect(movePanel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// Pure helpers for cross-section panel drag-and-drop. A single dnd-kit
|
||||
// DndContext (SectionList) hosts two draggable kinds — sections (reorder) and
|
||||
// panels (move between sections) — so ids are namespaced to tell them apart,
|
||||
// and section drop targets get their own id space.
|
||||
|
||||
const PANEL_DRAG_PREFIX = 'panel:';
|
||||
const SECTION_DROP_PREFIX = 'dropzone:';
|
||||
|
||||
/** dnd-kit draggable id for a panel grip. */
|
||||
export function panelDraggableId(panelId: string): string {
|
||||
return `${PANEL_DRAG_PREFIX}${panelId}`;
|
||||
}
|
||||
|
||||
/** Whether a dnd-kit active/over id belongs to a panel drag (vs a section). */
|
||||
export function isPanelDragId(id: string): boolean {
|
||||
return id.startsWith(PANEL_DRAG_PREFIX);
|
||||
}
|
||||
|
||||
/** dnd-kit droppable id for a section's panel drop zone, keyed by layout index. */
|
||||
export function sectionDropId(layoutIndex: number): string {
|
||||
return `${SECTION_DROP_PREFIX}${layoutIndex}`;
|
||||
}
|
||||
|
||||
/** Whether an id is a section panel drop zone. */
|
||||
export function isSectionDropId(id: string): boolean {
|
||||
return id.startsWith(SECTION_DROP_PREFIX);
|
||||
}
|
||||
|
||||
/** Layout index encoded in a section drop-zone id, or null if it isn't one. */
|
||||
export function layoutIndexFromDropId(id: string): number | null {
|
||||
if (!id.startsWith(SECTION_DROP_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
const value = Number(id.slice(SECTION_DROP_PREFIX.length));
|
||||
return Number.isInteger(value) ? value : null;
|
||||
}
|
||||
|
||||
/** Data carried on a panel draggable so the drop handler can build the move. */
|
||||
export interface PanelDragData {
|
||||
panelId: string;
|
||||
fromLayoutIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the move a panel drop implies, or null when it is a no-op (no drop
|
||||
* target, dropped over an unknown target, or dropped back on its own section).
|
||||
*/
|
||||
export function resolvePanelMove(
|
||||
drag: PanelDragData | null,
|
||||
overId: string | null,
|
||||
): { panelId: string; fromLayoutIndex: number; toLayoutIndex: number } | null {
|
||||
if (!drag || !overId) {
|
||||
return null;
|
||||
}
|
||||
const toLayoutIndex = layoutIndexFromDropId(overId);
|
||||
if (toLayoutIndex === null || toLayoutIndex === drag.fromLayoutIndex) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
panelId: drag.panelId,
|
||||
fromLayoutIndex: drag.fromLayoutIndex,
|
||||
toLayoutIndex,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import type {
|
||||
CollisionDetection,
|
||||
DragEndEvent,
|
||||
DragStartEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import { pointerWithin } from '@dnd-kit/core';
|
||||
|
||||
import type { DashboardSection, GridItem } from '../../../utils';
|
||||
import { useMovePanelToSection } from '../../Panel/hooks/useMovePanelToSection';
|
||||
import {
|
||||
isPanelDragId,
|
||||
isSectionDropId,
|
||||
type PanelDragData,
|
||||
resolvePanelMove,
|
||||
} from './crossSectionDrag';
|
||||
|
||||
interface Params {
|
||||
sections: DashboardSection[];
|
||||
}
|
||||
|
||||
interface Result {
|
||||
/** The panel item currently being dragged across sections (for the overlay). */
|
||||
activePanel: GridItem | null;
|
||||
/** True while a panel drag is in progress — the caller relaxes DnD modifiers. */
|
||||
isDraggingPanel: boolean;
|
||||
/** Collision detection scoped to section drop zones (panel drags only). */
|
||||
panelCollisionDetection: CollisionDetection;
|
||||
/** Returns true if it handled the event (a panel drag), false otherwise. */
|
||||
handleDragStart: (event: DragStartEvent) => boolean;
|
||||
handleDragEnd: (event: DragEndEvent) => boolean;
|
||||
handleDragCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns cross-section panel drag state within the section DndContext. A panel
|
||||
* grip drag is identified by its namespaced id; on drop over a different
|
||||
* section's zone the panel is relocated via the shared move hook (landing at the
|
||||
* target's bottom row). Section-reorder drags are left for the caller to handle.
|
||||
*/
|
||||
export function usePanelDragMove({ sections }: Params): Result {
|
||||
const movePanel = useMovePanelToSection({ sections });
|
||||
const [activeDrag, setActiveDrag] = useState<PanelDragData | null>(null);
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent): boolean => {
|
||||
if (!isPanelDragId(String(event.active.id))) {
|
||||
return false;
|
||||
}
|
||||
setActiveDrag((event.active.data.current as PanelDragData) ?? null);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent): boolean => {
|
||||
if (!isPanelDragId(String(event.active.id))) {
|
||||
return false;
|
||||
}
|
||||
const overId = event.over ? String(event.over.id) : null;
|
||||
const move = resolvePanelMove(activeDrag, overId);
|
||||
setActiveDrag(null);
|
||||
if (move) {
|
||||
void movePanel(move);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[activeDrag, movePanel],
|
||||
);
|
||||
|
||||
const handleDragCancel = useCallback((): void => {
|
||||
setActiveDrag(null);
|
||||
}, []);
|
||||
|
||||
// Only section drop zones are valid panel targets; ignore the sortable
|
||||
// section handles so a panel always resolves to the zone under the pointer.
|
||||
const panelCollisionDetection = useCallback<CollisionDetection>((args) => {
|
||||
const zones = args.droppableContainers.filter((c) =>
|
||||
isSectionDropId(String(c.id)),
|
||||
);
|
||||
return pointerWithin({ ...args, droppableContainers: zones });
|
||||
}, []);
|
||||
|
||||
const activePanel = useMemo<GridItem | null>(() => {
|
||||
if (!activeDrag) {
|
||||
return null;
|
||||
}
|
||||
const source = sections.find(
|
||||
(s) => s.layoutIndex === activeDrag.fromLayoutIndex,
|
||||
);
|
||||
return source?.items.find((i) => i.id === activeDrag.panelId) ?? null;
|
||||
}, [activeDrag, sections]);
|
||||
|
||||
return {
|
||||
activePanel,
|
||||
isDraggingPanel: activeDrag !== null,
|
||||
panelCollisionDetection,
|
||||
handleDragStart,
|
||||
handleDragEnd,
|
||||
handleDragCancel,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user