Replace react-dragula with dnd-kit (#1451)

* Replace react-dragula package with dnd-kit

* Refactor to function component

* Remove @types/dragula as well

* Move

* Initial implementation

* Ditch DragOverlay

* Fix function name

* Fix handling drag

* Fix scrolling issue

* Create gorgeous-meals-glow.md

* Fix app test

* Fix extension test

* Fix styling
This commit is contained in:
Nathan Bierema 2023-08-18 19:52:43 -04:00 committed by GitHub
parent 176a6cc59e
commit 14a795737b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 262 additions and 232 deletions

View File

@ -0,0 +1,5 @@
---
'@redux-devtools/inspector-monitor': patch
---
Replace react-dragula with dnd-kit

View File

@ -37,8 +37,6 @@ describe('App container', () => {
</Provider>,
);
const actionList = screen.getByTestId('actionList');
expect(
within(actionList).getByTestId('actionListRows'),
).toBeEmptyDOMElement();
expect(within(actionList).queryByRole('button')).not.toBeInTheDocument();
});
});

View File

@ -45,9 +45,7 @@ describe('App container', () => {
</Provider>,
);
const actionList = screen.getByTestId('actionList');
expect(
within(actionList).getByTestId('actionListRows'),
).toBeEmptyDOMElement();
expect(within(actionList).queryByRole('button')).not.toBeInTheDocument();
});
});

View File

@ -36,7 +36,10 @@
},
"dependencies": {
"@babel/runtime": "^7.22.6",
"@types/dragula": "^3.7.1",
"@dnd-kit/core": "^6.0.8",
"@dnd-kit/modifiers": "^6.0.1",
"@dnd-kit/sortable": "^7.0.2",
"@dnd-kit/utilities": "^3.2.1",
"@types/lodash": "^4.14.196",
"@types/prop-types": "^15.7.5",
"@types/redux-devtools-themes": "^1.0.0",
@ -50,7 +53,6 @@
"lodash.debounce": "^4.0.8",
"prop-types": "^15.8.1",
"react-base16-styling": "^0.9.1",
"react-dragula": "^1.1.17",
"react-json-tree": "^0.18.0",
"redux-devtools-themes": "^1.0.0"
},
@ -68,7 +70,6 @@
"@types/history": "^4.7.11",
"@types/lodash.debounce": "^4.0.7",
"@types/react": "^18.2.18",
"@types/react-dragula": "^1.1.0",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"eslint": "^8.46.0",

View File

@ -1,9 +1,24 @@
import React, { PureComponent, RefCallback } from 'react';
import { Drake } from 'dragula';
import dragula from 'react-dragula';
import React, { ReactNode, useCallback, useLayoutEffect, useRef } from 'react';
import { Action } from 'redux';
import { PerformAction } from '@redux-devtools/core';
import { StylingFunction } from 'react-base16-styling';
import {
closestCenter,
DndContext,
DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { restrictToFirstScrollableAncestor } from '@dnd-kit/modifiers';
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import ActionListRow from './ActionListRow';
import ActionListHeader from './ActionListHeader';
@ -21,6 +36,10 @@ function getTimestamps<A extends Action<unknown>>(
};
}
function scrollToBottom(node: HTMLDivElement) {
node.scrollTop = node.scrollHeight;
}
interface Props<A extends Action<unknown>> {
actions: { [actionId: number]: PerformAction<A> };
actionIds: number[];
@ -44,152 +63,172 @@ interface Props<A extends Action<unknown>> {
lastActionId: number;
}
export default class ActionList<
A extends Action<unknown>,
> extends PureComponent<Props<A>> {
node?: HTMLDivElement | null;
scrollDown?: boolean;
drake?: Drake;
export default function ActionList<A extends Action<unknown>>({
styling,
actions,
actionIds,
isWideLayout,
onToggleAction,
skippedActionIds,
selectedActionId,
startActionId,
onSelect,
onSearch,
searchValue,
currentActionId,
hideMainButtons,
hideActionButtons,
onCommit,
onSweep,
onJumpToState,
lastActionId,
onReorderAction,
}: Props<A>) {
const nodeRef = useRef<HTMLDivElement | null>(null);
const prevLastActionId = useRef<number | undefined>();
UNSAFE_componentWillReceiveProps(nextProps: Props<A>) {
const node = this.node;
if (!node) {
this.scrollDown = true;
} else if (this.props.lastActionId !== nextProps.lastActionId) {
const { scrollTop, offsetHeight, scrollHeight } = node;
this.scrollDown =
Math.abs(scrollHeight - (scrollTop + offsetHeight)) < 50;
} else {
this.scrollDown = false;
}
}
componentDidMount() {
this.scrollDown = true;
this.scrollToBottom();
if (!this.props.draggableActions) return;
const container = this.node!;
this.drake = dragula([container], {
copy: false,
copySortSource: false,
mirrorContainer: container,
accepts: (el, target, source, sibling) =>
!sibling || !!parseInt(sibling.getAttribute('data-id')!),
moves: (el, source, handle) =>
!!parseInt(el!.getAttribute('data-id')!) &&
handle!.className.indexOf('selectorButton') !== 0,
}).on('drop', (el, target, source, sibling) => {
let beforeActionId = this.props.actionIds.length;
if (sibling && sibling.className.indexOf('gu-mirror') === -1) {
beforeActionId = parseInt(sibling.getAttribute('data-id')!);
useLayoutEffect(() => {
if (nodeRef.current && prevLastActionId.current !== lastActionId) {
const { scrollTop, offsetHeight, scrollHeight } = nodeRef.current;
if (Math.abs(scrollHeight - (scrollTop + offsetHeight)) < 50) {
scrollToBottom(nodeRef.current);
}
const actionId = parseInt(el.getAttribute('data-id')!);
this.props.onReorderAction(actionId, beforeActionId);
});
}
componentWillUnmount() {
if (this.drake) this.drake.destroy();
}
componentDidUpdate() {
this.scrollToBottom();
}
scrollToBottom() {
if (this.scrollDown && this.node) {
this.node.scrollTop = this.node.scrollHeight;
}
}
getRef: RefCallback<HTMLDivElement> = (node) => {
this.node = node;
prevLastActionId.current = lastActionId;
}, [lastActionId]);
const setNodeRef = useCallback((node: HTMLDivElement | null) => {
if (node && !nodeRef.current) {
scrollToBottom(node);
}
nodeRef.current = node;
}, []);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const handleDragEnd = useCallback(
({ active, over }: DragEndEvent) => {
if (over && active.id !== over.id) {
const activeIndex = actionIds.indexOf(active.id as number);
const overIndex = actionIds.indexOf(over.id as number);
const beforeActionId =
overIndex < activeIndex
? (over.id as number)
: overIndex < actionIds.length - 1
? actionIds[overIndex + 1]
: actionIds.length;
onReorderAction(active.id as number, beforeActionId);
}
},
[actionIds, onReorderAction],
);
const lowerSearchValue = searchValue && searchValue.toLowerCase();
const filteredActionIds = searchValue
? actionIds.filter(
(id) =>
(actions[id].action.type as string)
.toLowerCase()
.indexOf(lowerSearchValue as string) !== -1,
)
: actionIds;
return (
<div
key="actionList"
data-testid="actionList"
{...styling(
['actionList', isWideLayout && 'actionListWide'],
isWideLayout,
)}
>
<ActionListHeader
styling={styling}
onSearch={onSearch}
onCommit={onCommit}
onSweep={onSweep}
hideMainButtons={hideMainButtons}
hasSkippedActions={skippedActionIds.length > 0}
hasStagedActions={actionIds.length > 1}
searchValue={searchValue}
/>
<div
data-testid="actionListRows"
{...styling('actionListRows')}
ref={setNodeRef}
>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
modifiers={[restrictToFirstScrollableAncestor]}
>
<SortableContext
items={filteredActionIds}
strategy={verticalListSortingStrategy}
>
{filteredActionIds.map((actionId) => (
<SortableItem key={actionId} actionId={actionId}>
<ActionListRow
styling={styling}
actionId={actionId}
isInitAction={!actionId}
isSelected={
(startActionId !== null &&
actionId >= startActionId &&
actionId <= (selectedActionId as number)) ||
actionId === selectedActionId
}
isInFuture={
actionIds.indexOf(actionId) >
actionIds.indexOf(currentActionId)
}
onSelect={(e: React.MouseEvent<HTMLDivElement>) =>
onSelect(e, actionId)
}
timestamps={getTimestamps(actions, actionIds, actionId)}
action={actions[actionId].action}
onToggleClick={() => onToggleAction(actionId)}
onJumpClick={() => onJumpToState(actionId)}
onCommitClick={() => onCommit()}
hideActionButtons={hideActionButtons}
isSkipped={skippedActionIds.indexOf(actionId) !== -1}
/>
</SortableItem>
))}
</SortableContext>
</DndContext>
</div>
</div>
);
}
interface SortableItemProps {
readonly children: ReactNode;
readonly actionId: number;
}
function SortableItem({ children, actionId }: SortableItemProps) {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: actionId });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
render() {
const {
styling,
actions,
actionIds,
isWideLayout,
onToggleAction,
skippedActionIds,
selectedActionId,
startActionId,
onSelect,
onSearch,
searchValue,
currentActionId,
hideMainButtons,
hideActionButtons,
onCommit,
onSweep,
onJumpToState,
} = this.props;
const lowerSearchValue = searchValue && searchValue.toLowerCase();
const filteredActionIds = searchValue
? actionIds.filter(
(id) =>
(actions[id].action.type as string)
.toLowerCase()
.indexOf(lowerSearchValue as string) !== -1,
)
: actionIds;
return (
<div
key="actionList"
data-testid="actionList"
{...styling(
['actionList', isWideLayout && 'actionListWide'],
isWideLayout,
)}
>
<ActionListHeader
styling={styling}
onSearch={onSearch}
onCommit={onCommit}
onSweep={onSweep}
hideMainButtons={hideMainButtons}
hasSkippedActions={skippedActionIds.length > 0}
hasStagedActions={actionIds.length > 1}
searchValue={searchValue}
/>
<div
data-testid="actionListRows"
{...styling('actionListRows')}
ref={this.getRef}
>
{filteredActionIds.map((actionId) => (
<ActionListRow
key={actionId}
styling={styling}
actionId={actionId}
isInitAction={!actionId}
isSelected={
(startActionId !== null &&
actionId >= startActionId &&
actionId <= (selectedActionId as number)) ||
actionId === selectedActionId
}
isInFuture={
actionIds.indexOf(actionId) > actionIds.indexOf(currentActionId)
}
onSelect={(e: React.MouseEvent<HTMLDivElement>) =>
onSelect(e, actionId)
}
timestamps={getTimestamps(actions, actionIds, actionId)}
action={actions[actionId].action}
onToggleClick={() => onToggleAction(actionId)}
onJumpClick={() => onJumpToState(actionId)}
onCommitClick={() => onCommit()}
hideActionButtons={hideActionButtons}
isSkipped={skippedActionIds.indexOf(actionId) !== -1}
/>
))}
</div>
</div>
);
}
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
{children}
</div>
);
}

View File

@ -86,23 +86,6 @@ const getSheetFromColorMap = (map: ColorMap) => ({
actionListRows: {
overflow: 'auto',
'& div.gu-transit': {
opacity: '0.3',
},
'& div.gu-mirror': {
position: 'fixed',
opacity: '0.8',
height: 'auto !important',
'border-width': '1px',
'border-style': 'solid',
'border-color': map.LIST_BORDER_COLOR,
},
'& div.gu-hide': {
display: 'none',
},
},
actionListHeaderSelector: {
@ -126,10 +109,6 @@ const getSheetFromColorMap = (map: ColorMap) => ({
cursor: 'pointer',
'user-select': 'none',
'&:last-child': {
'border-bottom-width': 0,
},
'border-bottom-color': map.BORDER_COLOR,
},

View File

@ -1623,9 +1623,18 @@ importers:
'@babel/runtime':
specifier: ^7.22.6
version: 7.22.6
'@types/dragula':
specifier: ^3.7.1
version: 3.7.1
'@dnd-kit/core':
specifier: ^6.0.8
version: 6.0.8(react-dom@18.2.0)(react@18.2.0)
'@dnd-kit/modifiers':
specifier: ^6.0.1
version: 6.0.1(@dnd-kit/core@6.0.8)(react@18.2.0)
'@dnd-kit/sortable':
specifier: ^7.0.2
version: 7.0.2(@dnd-kit/core@6.0.8)(react@18.2.0)
'@dnd-kit/utilities':
specifier: ^3.2.1
version: 3.2.1(react@18.2.0)
'@types/lodash':
specifier: ^4.14.196
version: 4.14.196
@ -1665,9 +1674,6 @@ importers:
react-base16-styling:
specifier: ^0.9.1
version: link:../react-base16-styling
react-dragula:
specifier: ^1.1.17
version: 1.1.17
react-json-tree:
specifier: ^0.18.0
version: link:../react-json-tree
@ -1714,9 +1720,6 @@ importers:
'@types/react':
specifier: ^18.2.18
version: 18.2.18
'@types/react-dragula':
specifier: ^1.1.0
version: 1.1.0
'@typescript-eslint/eslint-plugin':
specifier: ^5.62.0
version: 5.62.0(@typescript-eslint/parser@5.62.0)(eslint@8.46.0)(typescript@5.1.6)
@ -6474,6 +6477,61 @@ packages:
engines: {node: '>=10.0.0'}
dev: true
/@dnd-kit/accessibility@3.0.1(react@18.2.0):
resolution: {integrity: sha512-HXRrwS9YUYQO9lFRc/49uO/VICbM+O+ZRpFDe9Pd1rwVv2PCNkRiTZRdxrDgng/UkvdC3Re9r2vwPpXXrWeFzg==}
peerDependencies:
react: '>=16.8.0'
dependencies:
react: 18.2.0
tslib: 2.6.1
dev: false
/@dnd-kit/core@6.0.8(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-lYaoP8yHTQSLlZe6Rr9qogouGUz9oRUj4AHhDQGQzq/hqaJRpFo65X+JKsdHf8oUFBzx5A+SJPUvxAwTF2OabA==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
dependencies:
'@dnd-kit/accessibility': 3.0.1(react@18.2.0)
'@dnd-kit/utilities': 3.2.1(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
tslib: 2.6.1
dev: false
/@dnd-kit/modifiers@6.0.1(@dnd-kit/core@6.0.8)(react@18.2.0):
resolution: {integrity: sha512-rbxcsg3HhzlcMHVHWDuh9LCjpOVAgqbV78wLGI8tziXY3+qcMQ61qVXIvNKQFuhj75dSfD+o+PYZQ/NUk2A23A==}
peerDependencies:
'@dnd-kit/core': ^6.0.6
react: '>=16.8.0'
dependencies:
'@dnd-kit/core': 6.0.8(react-dom@18.2.0)(react@18.2.0)
'@dnd-kit/utilities': 3.2.1(react@18.2.0)
react: 18.2.0
tslib: 2.6.1
dev: false
/@dnd-kit/sortable@7.0.2(@dnd-kit/core@6.0.8)(react@18.2.0):
resolution: {integrity: sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA==}
peerDependencies:
'@dnd-kit/core': ^6.0.7
react: '>=16.8.0'
dependencies:
'@dnd-kit/core': 6.0.8(react-dom@18.2.0)(react@18.2.0)
'@dnd-kit/utilities': 3.2.1(react@18.2.0)
react: 18.2.0
tslib: 2.6.1
dev: false
/@dnd-kit/utilities@3.2.1(react@18.2.0):
resolution: {integrity: sha512-OOXqISfvBw/1REtkSK2N3Fi2EQiLMlWUlqnOK/UpOISqBZPWpE6TqL+jcPtMOkE8TqYGiURvRdPSI9hltNUjEA==}
peerDependencies:
react: '>=16.8.0'
dependencies:
react: 18.2.0
tslib: 2.6.1
dev: false
/@electron/get@2.0.2:
resolution: {integrity: sha512-eFZVFoRXb3GFGd7Ak7W4+6jBl9wBtiZ4AaYOse97ej6mKj5tkyO0dUnUChs1IhJZtx1BENo4/p4WUTXpi6vT+g==}
engines: {node: '>=12'}
@ -9932,9 +9990,6 @@ packages:
resolution: {integrity: sha512-w5jZ0ee+HaPOaX25X2/2oGR/7rgAQSYII7X7pp0m9KgBfMP7uKfMfTvcpl5Dj+eDBbpxKGiqE+flqDr6XTd2RA==}
dev: true
/@types/dragula@3.7.1:
resolution: {integrity: sha512-hbMEG5+wZEwV6NK4cbexldLWEvYNox8PywM9ICIeCTM99g8nJxccE3C8vvl66TCfnN+R8ioNdOrHWJT+5X0pvw==}
/@types/ejs@3.1.2:
resolution: {integrity: sha512-ZmiaE3wglXVWBM9fyVC17aGPkLo/UgaOjEiI2FXQfyczrCefORPxIe+2dVmnmk3zkVIbizjrlQzmPGhSYGXG5g==}
dev: true
@ -10247,12 +10302,6 @@ packages:
dependencies:
'@types/react': 18.2.18
/@types/react-dragula@1.1.0:
resolution: {integrity: sha512-wgRIVV2jo/Gria1PK3K26II7gfRD3VTcMfPYhL0CuIApSeon7xjBTj8Xs8Ln+Vbb/FuRKWfUaJXmF4R3KUGntA==}
dependencies:
'@types/dragula': 3.7.1
dev: true
/@types/react-test-renderer@18.0.0:
resolution: {integrity: sha512-C7/5FBJ3g3sqUahguGi03O79b8afNeSD6T8/GU50oQrJCU0bVCCGQHaGKUbg2Ce8VQEEqTw8/HiS6lXHHdgkdQ==}
dependencies:
@ -11337,10 +11386,6 @@ packages:
/asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
/atoa@1.0.0:
resolution: {integrity: sha512-VVE1H6cc4ai+ZXo/CRWoJiHXrA1qfA31DPnx6D20+kSI547hQN5Greh51LQ1baMRMfxO5K5M4ImMtZbZt2DODQ==}
dev: false
/available-typed-arrays@1.0.5:
resolution: {integrity: sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==}
engines: {node: '>= 0.4'}
@ -12369,13 +12414,6 @@ packages:
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
engines: {node: '>= 0.6'}
/contra@1.9.4:
resolution: {integrity: sha512-N9ArHAqwR/lhPq4OdIAwH4e1btn6EIZMAz4TazjnzCiVECcWUPTma+dRAM38ERImEJBh8NiCCpjoQruSZ+agYg==}
dependencies:
atoa: 1.0.0
ticky: 1.0.1
dev: false
/convert-source-map@1.9.0:
resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==}
@ -12526,12 +12564,6 @@ packages:
shebang-command: 2.0.0
which: 2.0.2
/crossvent@1.5.4:
resolution: {integrity: sha512-b6gEmNAh3kemyfNJ0LQzA/29A+YeGwevlSkNp2x0TzLOMYc0b85qRAD06OUuLWLQpR7HdJHNZQTlD1cfwoTrzg==}
dependencies:
custom-event: 1.0.0
dev: false
/crypt@0.0.2:
resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==}
dev: false
@ -12660,10 +12692,6 @@ packages:
stream-transform: 2.1.3
dev: true
/custom-event@1.0.0:
resolution: {integrity: sha512-6nOXX3UitrmdvSJWoVR2dlzhbX5bEUqmqsMUyx1ypCLZkHHkcuYtdpW3p94RGvcFkTV7DkLo+Ilbwnlwi8L+jw==}
dev: false
/d3-array@3.2.4:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
@ -13317,13 +13345,6 @@ packages:
engines: {node: '>=12'}
dev: true
/dragula@3.7.2:
resolution: {integrity: sha512-iDPdNTPZY7P/l0CQ800QiX+PNA2XF9iC3ePLWfGxeb/j8iPPedRuQdfSOfZrazgSpmaShYvYQ/jx7keWb4YNzA==}
dependencies:
contra: 1.9.4
crossvent: 1.5.4
dev: false
/duplexer@0.1.2:
resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==}
dev: true
@ -18836,13 +18857,6 @@ packages:
react: 18.2.0
scheduler: 0.23.0
/react-dragula@1.1.17:
resolution: {integrity: sha512-gJdY190sPWAyV8jz79vyK9SGk97bVOHjUguVNIYIEVosvt27HLxnbJo4qiuEkb/nAuGY13Im2CHup92fUyO3fw==}
dependencies:
atoa: 1.0.0
dragula: 3.7.2
dev: false
/react-element-to-jsx-string@15.0.0(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-UDg4lXB6BzlobN60P8fHWVPX3Kyw8ORrTeBtClmIlGdkOOE+GYQSFvmEU5iLLpwp/6v42DINwNcwOhOLfQ//FQ==}
peerDependencies:
@ -20760,10 +20774,6 @@ packages:
resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==}
dev: true
/ticky@1.0.1:
resolution: {integrity: sha512-RX35iq/D+lrsqhcPWIazM9ELkjOe30MSeoBHQHSsRwd1YuhJO5ui1K1/R0r7N3mFvbLBs33idw+eR6j+w6i/DA==}
dev: false
/tildify@2.0.0:
resolution: {integrity: sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==}
engines: {node: '>=8'}