Replacing react-sortable-hoc with dnd-kit (#1359)

## Launch Checklist

This PR replace react-sortable-hoc which is unmaintained with dnd-kit

- Resolves #1016
- Replaces the following PR: #1259

I've tested it locally to make sure it does what it should.
I'll see if I can add a test...

 - [x] Briefly describe the changes in this PR.
 - [x] Link to related issues.
 - [x] Write tests for all new functionality.
 - [x] Add an entry to `CHANGELOG.md` under the `## main` section.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Harel M
2025-09-08 22:08:32 +03:00
committed by GitHub
parent 03f3c5c032
commit 55a487d0c8
14 changed files with 326 additions and 142 deletions

View File

@@ -33,7 +33,6 @@ import { RevisionStore } from '../libs/revisions'
import LayerWatcher from '../libs/layerwatcher'
import tokens from '../config/tokens.json'
import isEqual from 'lodash.isequal'
import { SortEnd } from 'react-sortable-hoc';
import { MapOptions } from 'maplibre-gl';
import { OnStyleChangedOpts, StyleSpecificationWithId } from '../libs/definitions'
@@ -436,7 +435,7 @@ export default class App extends React.Component<any, AppState> {
if (errors.length > 0) {
dirtyMapStyle = cloneDeep(newStyle);
errors.forEach(error => {
for (const error of errors) {
const {message} = error;
if (message) {
try {
@@ -446,10 +445,10 @@ export default class App extends React.Component<any, AppState> {
unset(dirtyMapStyle, unsetPath);
}
catch (err) {
console.warn(err);
console.warn(message + " " + err);
}
}
});
}
}
if(newStyle.glyphs !== this.state.mapStyle.glyphs) {
@@ -496,7 +495,7 @@ export default class App extends React.Component<any, AppState> {
})
}
onMoveLayer = (move: SortEnd) => {
onMoveLayer = (move: {oldIndex: number; newIndex: number}) => {
let { oldIndex, newIndex } = move;
let layers = this.state.mapStyle.layers;
oldIndex = clamp(oldIndex, 0, layers.length-1);

View File

@@ -13,28 +13,30 @@ type FieldSourceInternalProps = {
error?: {message: string}
} & WithTranslation;
const FieldSourceInternal: React.FC<FieldSourceInternalProps> = (props) => {
const t = props.t;
const FieldSourceInternal: React.FC<FieldSourceInternalProps> = ({
onChange = () => {},
sourceIds = [],
wdKey,
value,
error,
t
}) => {
return (
<Block
label={t('Source')}
fieldSpec={latest.layer.source}
error={props.error}
data-wd-key={props.wdKey}
error={error}
data-wd-key={wdKey}
>
<InputAutocomplete
value={props.value}
onChange={props.onChange}
options={props.sourceIds?.map((src) => [src, src])}
value={value}
onChange={onChange}
options={sourceIds?.map((src) => [src, src])}
/>
</Block>
);
};
FieldSourceInternal.defaultProps = {
onChange: () => {},
sourceIds: [],
};
const FieldSource = withTranslation()(FieldSourceInternal);
export default FieldSource;

View File

@@ -9,33 +9,31 @@ type FieldSourceLayerInternalProps = {
value?: string
onChange?(...args: unknown[]): unknown
sourceLayerIds?: unknown[]
isFixed?: boolean
error?: {message: string}
} & WithTranslation;
const FieldSourceLayerInternal: React.FC<FieldSourceLayerInternalProps> = (props) => {
const t = props.t;
const FieldSourceLayerInternal: React.FC<FieldSourceLayerInternalProps> = ({
onChange = () => {},
sourceLayerIds = [],
value,
error,
t
}) => {
return (
<Block
label={t('Source Layer')}
fieldSpec={latest.layer['source-layer']}
data-wd-key="layer-source-layer"
error={props.error}
error={error}
>
<InputAutocomplete
value={props.value}
onChange={props.onChange}
options={props.sourceLayerIds?.map((l) => [l, l])}
value={value}
onChange={onChange}
options={sourceLayerIds?.map((l) => [l, l])}
/>
</Block>
);
};
FieldSourceLayerInternal.defaultProps = {
onChange: () => {},
sourceLayerIds: [],
isFixed: false,
};
const FieldSourceLayer = withTranslation()(FieldSourceLayerInternal);
export default FieldSourceLayer;

View File

@@ -22,6 +22,7 @@ import {formatLayerId} from '../libs/format';
import { WithTranslation, withTranslation } from 'react-i18next';
import { TFunction } from 'i18next';
import { NON_SOURCE_LAYERS } from '../libs/non-source-layers';
import { OnMoveLayerCallback } from '../libs/definitions';
type MaputnikLayoutGroup = {
id: string;
@@ -120,7 +121,7 @@ type LayerEditorInternalProps = {
spec: object
onLayerChanged(...args: unknown[]): unknown
onLayerIdChange(...args: unknown[]): unknown
onMoveLayer(...args: unknown[]): unknown
onMoveLayer: OnMoveLayerCallback
onLayerDestroy(...args: unknown[]): unknown
onLayerCopy(...args: unknown[]): unknown
onLayerVisibilityToggle(...args: unknown[]): unknown
@@ -322,30 +323,38 @@ class LayerEditorInternal extends React.Component<LayerEditorInternalProps, Laye
const layout = this.props.layer.layout || {}
const items: {[key: string]: {text: string, handler: () => void, disabled?: boolean}} = {
const items: {[key: string]: {
text: string,
handler: () => void,
disabled?: boolean,
wdKey?: string
}} = {
delete: {
text: t("Delete"),
handler: () => this.props.onLayerDestroy(this.props.layerIndex)
handler: () => this.props.onLayerDestroy(this.props.layerIndex),
wdKey: "menu-delete-layer"
},
duplicate: {
text: t("Duplicate"),
handler: () => this.props.onLayerCopy(this.props.layerIndex)
handler: () => this.props.onLayerCopy(this.props.layerIndex),
wdKey: "menu-duplicate-layer"
},
hide: {
text: (layout.visibility === "none") ? t("Show") : t("Hide"),
handler: () => this.props.onLayerVisibilityToggle(this.props.layerIndex)
handler: () => this.props.onLayerVisibilityToggle(this.props.layerIndex),
wdKey: "menu-hide-layer"
},
moveLayerUp: {
text: t("Move layer up"),
// Not actually used...
disabled: this.props.isFirstLayer,
handler: () => this.moveLayer(-1)
handler: () => this.moveLayer(-1),
wdKey: "menu-move-layer-up"
},
moveLayerDown: {
text: t("Move layer down"),
// Not actually used...
disabled: this.props.isLastLayer,
handler: () => this.moveLayer(+1)
handler: () => this.moveLayer(+1),
wdKey: "menu-move-layer-down"
}
}
@@ -382,7 +391,7 @@ class LayerEditorInternal extends React.Component<LayerEditorInternalProps, Laye
{Object.keys(items).map((id) => {
const item = items[id];
return <li key={id}>
<MenuItem value={id} className='more-menu__menu__item'>
<MenuItem value={id} className='more-menu__menu__item' data-wd-key={item.wdKey}>
{item.text}
</MenuItem>
</li>

View File

@@ -1,16 +1,28 @@
import React, {type JSX} from 'react'
import classnames from 'classnames'
import lodash from 'lodash';
import {
DndContext,
PointerSensor,
useSensor,
useSensors,
closestCenter,
DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import LayerListGroup from './LayerListGroup'
import LayerListItem from './LayerListItem'
import ModalAdd from './ModalAdd'
import {SortEndHandler, SortableContainer} from 'react-sortable-hoc';
import type {LayerSpecification, SourceSpecification} from 'maplibre-gl';
import generateUniqueId from '../libs/document-uid';
import { findClosestCommonPrefix, layerPrefix } from '../libs/layer';
import { WithTranslation, withTranslation } from 'react-i18next';
import { OnMoveLayerCallback } from '../libs/definitions';
type LayerListContainerProps = {
layers: LayerSpecification[]
@@ -242,7 +254,6 @@ class LayerListContainerInternal extends React.Component<LayerListContainerInter
'maputnik-layer-list-item-group-last': idxInGroup == layers.length - 1 && layers.length > 1,
'maputnik-layer-list-item--error': !!layerError
})}
index={idx}
key={layer.key}
id={layer.key}
layerId={layer.id}
@@ -319,20 +330,35 @@ class LayerListContainerInternal extends React.Component<LayerListContainerInter
}
const LayerListContainer = withTranslation()(LayerListContainerInternal);
const LayerListContainerSortable = SortableContainer((props: LayerListContainerProps) => <LayerListContainer {...props} />)
type LayerListProps = LayerListContainerProps & {
onMoveLayer: SortEndHandler
onMoveLayer: OnMoveLayerCallback
};
export default class LayerList extends React.Component<LayerListProps> {
render() {
return <LayerListContainerSortable
{...this.props}
helperClass='sortableHelper'
onSortEnd={this.props.onMoveLayer.bind(this)}
useDragHandle={true}
shouldCancelStart={() => false}
/>
}
const LayerList: React.FC<LayerListProps> = (props) => {
const sensors = useSensors(useSensor(PointerSensor));
const handleDragEnd = (event: DragEndEvent) => {
const {active, over} = event;
if (!over) return;
const oldIndex = props.layers.findIndex(layer => layer.id === active.id);
const newIndex = props.layers.findIndex(layer => layer.id === over.id);
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
props.onMoveLayer({oldIndex, newIndex});
}
};
const layerIds = props.layers.map(layer => layer.id);
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={layerIds} strategy={verticalListSortingStrategy}>
<LayerListContainer {...props} />
</SortableContext>
</DndContext>
);
}
export default LayerList;

View File

@@ -1,20 +1,23 @@
import React from 'react'
import classnames from 'classnames'
import {MdContentCopy, MdVisibility, MdVisibilityOff, MdDelete} from 'react-icons/md'
import { IconContext } from 'react-icons'
import {useSortable} from '@dnd-kit/sortable'
import {CSS} from '@dnd-kit/utilities'
import IconLayer from './IconLayer'
import {SortableElement, SortableHandle} from 'react-sortable-hoc'
type DraggableLabelProps = {
layerId: string
layerType: string
dragAttributes?: React.HTMLAttributes<HTMLElement>
dragListeners?: React.HTMLAttributes<HTMLElement>
};
const DraggableLabel = SortableHandle((props: DraggableLabelProps) => {
return <div className="maputnik-layer-list-item-handle">
const DraggableLabel: React.FC<DraggableLabelProps> = (props) => {
const {dragAttributes, dragListeners} = props;
return <div className="maputnik-layer-list-item-handle" {...dragAttributes} {...dragListeners}>
<IconLayer
className="layer-handle__icon"
type={props.layerType}
@@ -23,7 +26,7 @@ const DraggableLabel = SortableHandle((props: DraggableLabelProps) => {
{props.layerId}
</button>
</div>
});
};
type IconActionProps = {
action: string
@@ -82,55 +85,80 @@ type LayerListItemProps = {
onLayerVisibilityToggle?(...args: unknown[]): unknown
};
class LayerListItem extends React.Component<LayerListItemProps> {
static defaultProps = {
isSelected: false,
visibility: 'visible',
onLayerCopy: () => {},
onLayerDestroy: () => {},
onLayerVisibilityToggle: () => {},
}
const LayerListItem = React.forwardRef<HTMLLIElement, LayerListItemProps>((props, ref) => {
const {
isSelected = false,
visibility = 'visible',
onLayerCopy = () => {},
onLayerDestroy = () => {},
onLayerVisibilityToggle = () => {},
} = props;
render() {
const visibilityAction = this.props.visibility === 'visible' ? 'show' : 'hide';
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({id: props.layerId});
return <IconContext.Provider value={{size: '14px'}}>
<li
id={this.props.id}
key={this.props.layerId}
onClick={_e => this.props.onLayerSelect(this.props.layerIndex)}
data-wd-key={"layer-list-item:"+this.props.layerId}
className={classnames({
"maputnik-layer-list-item": true,
"maputnik-layer-list-item-selected": this.props.isSelected,
[this.props.className!]: true,
})}>
<DraggableLabel {...this.props} />
<span style={{flexGrow: 1}} />
<IconAction
wdKey={"layer-list-item:"+this.props.layerId+":delete"}
action={'delete'}
classBlockName="delete"
onClick={_e => this.props.onLayerDestroy!(this.props.layerIndex)}
/>
<IconAction
wdKey={"layer-list-item:"+this.props.layerId+":copy"}
action={'duplicate'}
classBlockName="duplicate"
onClick={_e => this.props.onLayerCopy!(this.props.layerIndex)}
/>
<IconAction
wdKey={"layer-list-item:"+this.props.layerId+":toggle-visibility"}
action={visibilityAction}
classBlockName="visibility"
classBlockModifier={visibilityAction}
onClick={_e => this.props.onLayerVisibilityToggle!(this.props.layerIndex)}
/>
</li>
</IconContext.Provider>
}
}
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const LayerListItemSortable = SortableElement<LayerListItemProps>((props: LayerListItemProps) => <LayerListItem {...props} />);
const visibilityAction = visibility === 'visible' ? 'show' : 'hide';
export default LayerListItemSortable;
// Cast ref to MutableRefObject since we know from the codebase that's what's always passed
const refObject = ref as React.MutableRefObject<HTMLLIElement | null> | null;
return <IconContext.Provider value={{size: '14px'}}>
<li
ref={(node) => {
setNodeRef(node);
if (refObject) {
refObject.current = node;
}
}}
style={style}
id={props.id}
onClick={_e => props.onLayerSelect(props.layerIndex)}
data-wd-key={"layer-list-item:" + props.layerId}
className={classnames({
"maputnik-layer-list-item": true,
"maputnik-layer-list-item-selected": isSelected,
[props.className!]: true,
})}>
<DraggableLabel
layerId={props.layerId}
layerType={props.layerType}
dragAttributes={attributes}
dragListeners={listeners}
/>
<span style={{flexGrow: 1}} />
<IconAction
wdKey={"layer-list-item:" + props.layerId+":delete"}
action={'delete'}
classBlockName="delete"
onClick={_e => onLayerDestroy!(props.layerIndex)}
/>
<IconAction
wdKey={"layer-list-item:" + props.layerId+":copy"}
action={'duplicate'}
classBlockName="duplicate"
onClick={_e => onLayerCopy!(props.layerIndex)}
/>
<IconAction
wdKey={"layer-list-item:"+props.layerId+":toggle-visibility"}
action={visibilityAction}
classBlockName="visibility"
classBlockModifier={visibilityAction}
onClick={_e => onLayerVisibilityToggle!(props.layerIndex)}
/>
</li>
</IconContext.Provider>
});
export default LayerListItem;

View File

@@ -175,7 +175,6 @@ class ModalAddInternal extends React.Component<ModalAddInternalProps, ModalAddSt
}
{!NON_SOURCE_LAYERS.includes(this.state.type) &&
<FieldSourceLayer
isFixed={true}
sourceLayerIds={layers}
value={this.state['source-layer']}
onChange={(v: string) => this.setState({ 'source-layer': v })}

View File

@@ -10,6 +10,8 @@ export type OnStyleChangedOpts = {
export type OnStyleChangedCallback = (newStyle: StyleSpecificationWithId, opts: OnStyleChangedOpts={}) => void;
export type OnMoveLayerCallback = (move: {oldIndex: number; newIndex: number}) => void;
export interface IStyleStore {
getLatestStyle(): Promise<StyleSpecificationWithId>;
save(mapStyle: StyleSpecificationWithId): StyleSpecificationWithId;