Skip to content

Commit

Permalink
Eliminate the need to provide index in DragAndDrop (#11252)
Browse files Browse the repository at this point in the history
  • Loading branch information
TomasEng authored Oct 2, 2023
1 parent 65d80e9 commit 9d35dbf
Show file tree
Hide file tree
Showing 14 changed files with 539 additions and 28 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import React, { ReactNode } from 'react';
import { DragAndDropList } from './';
import {
DragAndDropListItemContext,
DragAndDropListItemContextProps,
} from '../DragAndDropListItem/DragAndDropListItemContext';
import {
DragAndDropRootContext,
DragAndDropRootContextProps,
} from '../DragAndDropProvider/DragAndDropRootContext';
import { render as renderRtl } from '@testing-library/react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { domListClass, domListId } from 'app-shared/components/dragAndDrop/utils/domUtils';

//
const itemId = 'id';
const rootId = 'rootId';
const uniqueDomId = ':r0:';
const onDrop = jest.fn();
const defaultlistItemContextProps: DragAndDropListItemContextProps = {
isDisabled: false,
itemId,
};
const defaultRootContextProps: DragAndDropRootContextProps<string> = {
onDrop,
rootId,
uniqueDomId,
};

/* eslint-disable testing-library/no-node-access */
describe('DragAndDropList', () => {
it('Renders with correct id and class name', () => {
const { container } = render()(<div />);
const expectedId = domListId(uniqueDomId, itemId);
const expectedClass = domListClass(uniqueDomId);
expect(container.firstChild).toHaveAttribute('id', expectedId);
expect(container.firstChild).toHaveClass(expectedClass);
});
});

interface RenderProps {
listItemContextProps?: Partial<DragAndDropListItemContextProps>;
rootContextProps?: Partial<DragAndDropRootContextProps<string>>;
}

function render({ listItemContextProps = {}, rootContextProps = {} }: RenderProps = {}) {
return (children: ReactNode) =>
renderRtl(
<DndProvider backend={HTML5Backend}>
<DragAndDropRootContext.Provider
value={{ ...rootContextProps, ...defaultRootContextProps }}
>
<DragAndDropListItemContext.Provider
value={{ ...listItemContextProps, ...defaultlistItemContextProps }}
>
<DragAndDropList>{children}</DragAndDropList>
</DragAndDropListItemContext.Provider>
</DragAndDropRootContext.Provider>
</DndProvider>,
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as testids from '../../../../../../testing/testids';
import { useIsParentDisabled } from '../hooks/useIsParentDisabled';
import { useParentId } from '../hooks/useParentId';
import { useOnDrop } from 'app-shared/components/dragAndDrop/hooks/useOnDrop';
import { useDomSelectors } from 'app-shared/components/dragAndDrop/hooks/useDomSelectors';

export interface DragAndDropListProps {
/** The list of existing items. */
Expand All @@ -19,10 +20,11 @@ export interface DragAndDropListCollectedProps {
export function DragAndDropList<T>({ children }: DragAndDropListProps) {
const disabledDrop = useIsParentDisabled();
const parentId = useParentId();
const domSelectors = useDomSelectors(parentId);
const onDrop = useOnDrop<T>();
const canDrop = useCallback(
(monitor: DropTargetMonitor) => monitor.isOver({ shallow: true }) && !disabledDrop,
[disabledDrop]
[disabledDrop],
);
const [{ canBeDropped }, drop] = useDrop<DndItem<T>, unknown, DragAndDropListCollectedProps>({
accept: Object.values(DraggableEditorItemType),
Expand All @@ -38,8 +40,9 @@ export function DragAndDropList<T>({ children }: DragAndDropListProps) {
const backgroundColor = canBeDropped ? 'var(--list-empty-space-hover-color)' : 'transparent';
return (
<div
className={classes.root}
className={classes.root + ' ' + domSelectors.list.className}
data-testid={testids.droppableList}
id={domSelectors.list.id}
ref={drop}
style={{ backgroundColor }}
>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import React from 'react';
import { DragAndDropListItem, DragAndDropListItemProps } from './DragAndDropListItem';
import {
DragAndDropListItemContext,
DragAndDropListItemContextProps,
} from './DragAndDropListItemContext';
import {
DragAndDropRootContext,
DragAndDropRootContextProps,
} from '../DragAndDropProvider/DragAndDropRootContext';
import { render as renderRtl } from '@testing-library/react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { domItemClass, domItemId } from 'app-shared/components/dragAndDrop/utils/domUtils';

//
const itemId = 'id';
const parentId = 'parentId';
const rootId = 'rootId';
const uniqueDomId = ':r0:';
const onDrop = jest.fn();
const renderItem = () => <div>test</div>;
const defaultlistItemProps: DragAndDropListItemProps = {
itemId,
renderItem,
};
const defaultlistItemContextProps: DragAndDropListItemContextProps = {
isDisabled: false,
itemId: parentId,
};
const defaultRootContextProps: DragAndDropRootContextProps<string> = {
onDrop,
rootId,
uniqueDomId,
};

/* eslint-disable testing-library/no-node-access */
describe('DragAndDropListItem', () => {
it('Renders with correct id and class name', () => {
const { container } = render();
const expectedId = domItemId(uniqueDomId, itemId);
const expectedClass = domItemClass(uniqueDomId);
expect(container.firstChild).toHaveAttribute('id', expectedId);
expect(container.firstChild).toHaveClass(expectedClass);
});
});

interface RenderProps {
listItemProps?: Partial<DragAndDropListItemProps>;
listItemContextProps?: Partial<DragAndDropListItemContextProps>;
rootContextProps?: Partial<DragAndDropRootContextProps<string>>;
}

function render({
listItemProps = {},
listItemContextProps = {},
rootContextProps = {},
}: RenderProps = {}) {
return renderRtl(
<DndProvider backend={HTML5Backend}>
<DragAndDropRootContext.Provider value={{ ...rootContextProps, ...defaultRootContextProps }}>
<DragAndDropListItemContext.Provider
value={{ ...listItemContextProps, ...defaultlistItemContextProps }}
>
<DragAndDropListItem<string> {...listItemProps} {...defaultlistItemProps} />
</DragAndDropListItemContext.Provider>
</DragAndDropRootContext.Provider>
</DndProvider>,
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,18 @@ import {
DndItem,
ExistingDndItem,
} from '../../../types/dndTypes';
import React, { ReactNode, useMemo, useRef, useState } from 'react';
import React, { ReactNode, useCallback, useMemo, useRef, useState } from 'react';
import { ConnectDragSource, useDrag, useDrop } from 'react-dnd';
import { calculateNewPosition, getDragCursorPosition } from '../../../utils/dndUtils';
import classes from './DragAndDropListItem.module.css';
import { useIsParentDisabled } from '../hooks/useIsParentDisabled';
import { DragAndDropListItemContext } from '../DragAndDropListItem';
import { useParentId } from '../hooks/useParentId';
import { useOnDrop } from 'app-shared/components/dragAndDrop/hooks/useOnDrop';
import { useDomSelectors } from 'app-shared/components/dragAndDrop/hooks/useDomSelectors';
import { findPositionInList } from 'app-shared/components/dragAndDrop/utils/domUtils';

export interface DragAndDropListItemProps<T> {
/** The index of the item. */
index: number;

export interface DragAndDropListItemProps {
/** The id of the item. */
itemId: string;

Expand All @@ -28,14 +27,15 @@ interface DragCollectedProps {
isDragging: boolean;
}

export function DragAndDropListItem<T>({ index, itemId, renderItem }: DragAndDropListItemProps<T>) {
export function DragAndDropListItem<T>({ itemId, renderItem }: DragAndDropListItemProps) {
const wrapperRef = useRef<HTMLDivElement>(null);
const [dragCursorPosition, setDragCursorPosition] = useState<DragCursorPosition>(
DragCursorPosition.Outside
DragCursorPosition.Outside,
);
const isParentDisabled = useIsParentDisabled();
const parentId = useParentId();
const onDrop = useOnDrop<T>();
const domSelectors = useDomSelectors(itemId);

const boxShadow = useMemo(() => {
switch (dragCursorPosition) {
Expand All @@ -48,13 +48,13 @@ export function DragAndDropListItem<T>({ index, itemId, renderItem }: DragAndDro
}
}, [dragCursorPosition]);

const item: ExistingDndItem = useMemo(
const item: () => ExistingDndItem = useCallback(
() => ({
isNew: false,
id: itemId,
position: { index, parentId },
position: { index: findPositionInList(domSelectors.baseId, itemId), parentId },
}),
[index, itemId, parentId]
[itemId, parentId, domSelectors.baseId],
);

const [{ isDragging }, drag, dragPreview] = useDrag<DndItem<T>, unknown, DragCollectedProps>({
Expand All @@ -68,7 +68,7 @@ export function DragAndDropListItem<T>({ index, itemId, renderItem }: DragAndDro
const [, drop] = useDrop<DndItem<T>, unknown, void>({
accept: Object.values(DraggableEditorItemType),
drop: (draggedItem) => {
const position = calculateNewPosition<T>(draggedItem, item, dragCursorPosition);
const position = calculateNewPosition<T>(draggedItem, item(), dragCursorPosition);
if (position) onDrop(draggedItem, position);
setDragCursorPosition(DragCursorPosition.Idle);
},
Expand All @@ -77,9 +77,9 @@ export function DragAndDropListItem<T>({ index, itemId, renderItem }: DragAndDro
const currentDragPosition = getDragCursorPosition<T>(
monitor,
draggedItem,
item,
item(),
wrapperRef,
isParentDisabled
isParentDisabled,
);
if (currentDragPosition !== dragCursorPosition) setDragCursorPosition(currentDragPosition);
},
Expand All @@ -93,7 +93,7 @@ export function DragAndDropListItem<T>({ index, itemId, renderItem }: DragAndDro
const opacity = isDragging ? 0.25 : 1;

return (
<div ref={wrapperRef}>
<div ref={wrapperRef} {...domSelectors.item}>
<div ref={drop} className={classes.wrapper}>
<div ref={dragPreview} style={{ opacity, boxShadow }}>
<DragAndDropListItemContext.Provider
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { createContext } from 'react';

export type DragDropListItemContextProps = {
export type DragAndDropListItemContextProps = {
isDisabled: boolean;
itemId: string;
};

export const DragAndDropListItemContext = createContext<DragDropListItemContextProps>({
export const DragAndDropListItemContext = createContext<DragAndDropListItemContextProps>({
isDisabled: false,
itemId: null,
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { ReactNode } from 'react';
import React, { ReactNode, useId } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { DragAndDropRootContext } from './DragAndDropRootContext';
Expand All @@ -19,9 +19,10 @@ export function DragAndDropProvider<T>({
}: DragAndDropProviderProps<T>) {
const onDrop: HandleDrop<T> = (item, position) =>
item.isNew === true ? onAdd(item.payload, position) : onMove(item.id, position);
const uniqueDomId = useId(); // Can not be the same as root id because root id might not be unique (if there are multiple drag and drop lists)
return (
<DndProvider backend={HTML5Backend}>
<DragAndDropRootContext.Provider value={{ rootId, onDrop }}>
<DragAndDropRootContext.Provider value={{ rootId, onDrop, uniqueDomId }}>
{children}
</DragAndDropRootContext.Provider>
</DndProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { HandleDrop } from 'app-shared/types/dndTypes';
export interface DragAndDropRootContextProps<T> {
rootId: string;
onDrop: HandleDrop<T>;
uniqueDomId: string;
}

export const DragAndDropRootContext = createContext<DragAndDropRootContextProps<unknown>>(null);
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import React from 'react';
import { renderHook } from '@testing-library/react';
import { useDomSelectors } from 'app-shared/components/dragAndDrop/hooks/useDomSelectors';
import { DragAndDropRootContext } from 'app-shared/components/dragAndDrop/DragAndDropProvider';
import {
extractIdFromDomItemId,
extractIdFromDomListId,
} from 'app-shared/components/dragAndDrop/utils/domUtils';

// Test data:
const id = 'id';
const uniqueDomId = 'baseId';

describe('useDomSelectors', () => {
afterEach(jest.clearAllMocks);

it('Returns the base id and selector attributes for list and item components with the given id', () => {
const { result } = renderHook(() => useDomSelectors(id), {
wrapper: ({ children }) => (
<DragAndDropRootContext.Provider
value={{ uniqueDomId, rootId: 'rootId', onDrop: jest.fn() }}
>
{children}
</DragAndDropRootContext.Provider>
),
});
expect(result.current).toEqual({
baseId: uniqueDomId,
list: {
id: expect.any(String),
className: expect.any(String),
},
item: {
id: expect.any(String),
className: expect.any(String),
},
});
const { list, item } = result.current;
expect(extractIdFromDomListId(uniqueDomId, list.id)).toEqual(id);
expect(extractIdFromDomItemId(uniqueDomId, item.id)).toEqual(id);
});

it('Throws an error if not wrapped by a DragAndDropProvider', () => {
jest.spyOn(console, 'error').mockImplementation();
const renderFn = () => renderHook(() => useDomSelectors(id));
expect(renderFn).toThrow(
new Error('useDomSelectors must be used within a DragAndDropRootContext provider.'),
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { DragAndDropRootContext } from 'app-shared/components/dragAndDrop/DragAndDropProvider';
import { useContext } from 'react';
import {
domItemClass,
domItemId,
domListClass,
domListId,
} from 'app-shared/components/dragAndDrop/utils/domUtils';

interface Attributes {
id: string;
className: string;
}

interface DomSelectors {
baseId: string;
item: Attributes;
list: Attributes;
}

export function useDomSelectors(itemId: string): DomSelectors {
const context = useContext(DragAndDropRootContext);
if (!context) {
throw new Error('useDomSelectors must be used within a DragAndDropRootContext provider.');
}
return {
baseId: context.uniqueDomId,
item: {
id: domItemId(context.uniqueDomId, itemId),
className: domItemClass(context.uniqueDomId),
},
list: {
id: domListId(context.uniqueDomId, itemId),
className: domListClass(context.uniqueDomId),
},
};
}
Loading

0 comments on commit 9d35dbf

Please sign in to comment.