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
+1
View File
@@ -18,6 +18,7 @@
- Replace react-autocomplete with Downshift in the autocomplete component - Replace react-autocomplete with Downshift in the autocomplete component
- Add LocationIQ as supported map provider with access token field and gallery style - Add LocationIQ as supported map provider with access token field and gallery style
- Revmove support for `debug` and `localport` url parameters - Revmove support for `debug` and `localport` url parameters
- Replace react-sortable-hoc with dnd-kit to avoid react console warnings and also use a maintained library
- _...Add new stuff here..._ - _...Add new stuff here..._
### 🐞 Bug fixes ### 🐞 Bug fixes
+89 -4
View File
@@ -378,8 +378,57 @@ describe("layers", () => {
}); });
it("groups", () => { it("groups", () => {
// TODO when.modal.open();
// Click each of the layer groups. const id1 = when.modal.fillLayers({
id: "aa",
type: "line",
layer: "example",
});
when.modal.open();
const id2 = when.modal.fillLayers({
id: "aa-2",
type: "line",
layer: "example",
});
when.modal.open();
const id3 = when.modal.fillLayers({
id: "b",
type: "line",
layer: "example",
});
then(get.elementByTestId("layer-list-item:" + id1)).shouldBeVisible();
then(get.elementByTestId("layer-list-item:" + id2)).shouldNotBeVisible();
then(get.elementByTestId("layer-list-item:" + id3)).shouldBeVisible();
when.click("layer-list-group:aa-0");
then(get.elementByTestId("layer-list-item:" + id1)).shouldBeVisible();
then(get.elementByTestId("layer-list-item:" + id2)).shouldBeVisible();
then(get.elementByTestId("layer-list-item:" + id3)).shouldBeVisible();
when.click("layer-list-item:" + id2);
when.click("skip-target-layer-editor");
when.click("menu-move-layer-down");
then(get.elementByTestId("layer-list-group:aa-0")).shouldNotExist();
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "aa",
type: "line",
source: "example",
},
{
id: "b",
type: "line",
source: "example",
},
{
id: "aa-2",
type: "line",
source: "example",
},
],
});
}); });
}); });
@@ -495,9 +544,7 @@ describe("layers", () => {
}); });
}); });
describe("layereditor jsonlint should error", ()=>{ describe("layereditor jsonlint should error", ()=>{
it("add", () => { it("add", () => {
const id = when.modal.fillLayers({ const id = when.modal.fillLayers({
type: "circle", type: "circle",
@@ -523,4 +570,42 @@ describe("layers", () => {
error.should('exist'); error.should('exist');
}); });
}); });
describe("drag and drop", () => {
it("move layer should update local storage", () => {
when.modal.open();
const firstId = when.modal.fillLayers({
id: "a",
type: "background",
});
when.modal.open();
const secondId = when.modal.fillLayers({
id: "b",
type: "background",
});
when.modal.open();
const thirdId = when.modal.fillLayers({
id: "c",
type: "background",
});
when.dragAndDrop(get.elementByTestId("layer-list-item:" + firstId), get.elementByTestId("layer-list-item:" + thirdId));
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: secondId,
type: "background",
},
{
id: thirdId,
type: "background",
},
{
id: firstId,
type: "background",
},
],
});
});
});
}); });
+3 -3
View File
@@ -94,8 +94,8 @@ export class MaputnikDriver {
public when = { public when = {
...this.helper.when, ...this.helper.when,
modal: this.modalDriver.when, modal: this.modalDriver.when,
within: (selector: string, fn: () => void) => { doWithin: (selector: string, fn: () => void) => {
this.helper.when.within(fn, selector); this.helper.when.doWithin(fn, selector);
}, },
tab: () => this.helper.get.element("body").tab(), tab: () => this.helper.get.element("body").tab(),
waitForExampleFileResponse: () => { waitForExampleFileResponse: () => {
@@ -145,7 +145,7 @@ export class MaputnikDriver {
}, },
selectWithin: (selector: string, value: string) => { selectWithin: (selector: string, value: string) => {
this.when.within(selector, () => { this.when.doWithin(selector, () => {
this.helper.get.element("select").select(value); this.helper.get.element("select").select(value);
}); });
}, },
+2 -2
View File
@@ -20,8 +20,8 @@ export default class ModalDriver {
this.helper.when.type("add-layer.layer-id.input", id); this.helper.when.type("add-layer.layer-id.input", id);
if (layer) { if (layer) {
this.helper.when.within(() => { this.helper.when.doWithin(() => {
this.helper.get.element("input").type(layer!); this.helper.get.element("input").clear().type(layer!);
}, "add-layer.layer-source-block"); }, "add-layer.layer-source-block");
} }
this.helper.when.click("add-layer"); this.helper.when.click("add-layer");
+57 -24
View File
@@ -9,6 +9,9 @@
"version": "2.1.1", "version": "2.1.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@mapbox/mapbox-gl-rtl-text": "^0.3.0", "@mapbox/mapbox-gl-rtl-text": "^0.3.0",
"@maplibre/maplibre-gl-geocoder": "^1.9.0", "@maplibre/maplibre-gl-geocoder": "^1.9.0",
"@maplibre/maplibre-gl-inspect": "^1.7.1", "@maplibre/maplibre-gl-inspect": "^1.7.1",
@@ -54,7 +57,6 @@
"react-i18next": "^15.7.3", "react-i18next": "^15.7.3",
"react-icon-base": "^2.1.2", "react-icon-base": "^2.1.2",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-sortable-hoc": "^2.0.0",
"reconnecting-websocket": "^4.4.0", "reconnecting-websocket": "^4.4.0",
"slugify": "^1.6.6", "slugify": "^1.6.6",
"string-hash": "^1.1.3", "string-hash": "^1.1.3",
@@ -698,6 +700,59 @@
"ms": "^2.1.1" "ms": "^2.1.1"
} }
}, },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dual-bundle/import-meta-resolve": { "node_modules/@dual-bundle/import-meta-resolve": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", "resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz",
@@ -3779,6 +3834,7 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/array-move/-/array-move-4.0.0.tgz", "resolved": "https://registry.npmjs.org/array-move/-/array-move-4.0.0.tgz",
"integrity": "sha512-+RY54S8OuVvg94THpneQvFRmqWdAHeqtMzgMW6JNurHxe8rsS07cHQdfGkXnTUXiBcyZ0j3SiDIxxj0RPiqCkQ==", "integrity": "sha512-+RY54S8OuVvg94THpneQvFRmqWdAHeqtMzgMW6JNurHxe8rsS07cHQdfGkXnTUXiBcyZ0j3SiDIxxj0RPiqCkQ==",
"license": "MIT",
"engines": { "engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0" "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}, },
@@ -7569,14 +7625,6 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
"integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/is-array-buffer": { "node_modules/is-array-buffer": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -10899,21 +10947,6 @@
"react-dom": ">=16.8" "react-dom": ">=16.8"
} }
}, },
"node_modules/react-sortable-hoc": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/react-sortable-hoc/-/react-sortable-hoc-2.0.0.tgz",
"integrity": "sha512-JZUw7hBsAHXK7PTyErJyI7SopSBFRcFHDjWW5SWjcugY0i6iH7f+eJkY8cJmGMlZ1C9xz1J3Vjz0plFpavVeRg==",
"dependencies": {
"@babel/runtime": "^7.2.0",
"invariant": "^2.2.4",
"prop-types": "^15.5.7"
},
"peerDependencies": {
"prop-types": "^15.5.7",
"react": "^16.3.0 || ^17.0.0",
"react-dom": "^16.3.0 || ^17.0.0"
}
},
"node_modules/reactcss": { "node_modules/reactcss": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz", "resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz",
+4 -2
View File
@@ -23,6 +23,9 @@
"license": "MIT", "license": "MIT",
"homepage": "https://github.com/maplibre/maputnik#readme", "homepage": "https://github.com/maplibre/maputnik#readme",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@mapbox/mapbox-gl-rtl-text": "^0.3.0", "@mapbox/mapbox-gl-rtl-text": "^0.3.0",
"@maplibre/maplibre-gl-geocoder": "^1.9.0", "@maplibre/maplibre-gl-geocoder": "^1.9.0",
"@maplibre/maplibre-gl-inspect": "^1.7.1", "@maplibre/maplibre-gl-inspect": "^1.7.1",
@@ -36,6 +39,7 @@
"codemirror": "^5.65.20", "codemirror": "^5.65.20",
"color": "^5.0.0", "color": "^5.0.0",
"detect-browser": "^5.3.0", "detect-browser": "^5.3.0",
"downshift": "^9.0.10",
"events": "^3.3.0", "events": "^3.3.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"i18next": "^25.5.2", "i18next": "^25.5.2",
@@ -60,7 +64,6 @@
"react-accessible-accordion": "^5.0.1", "react-accessible-accordion": "^5.0.1",
"react-aria-menubutton": "^7.0.3", "react-aria-menubutton": "^7.0.3",
"react-aria-modal": "^5.0.2", "react-aria-modal": "^5.0.2",
"downshift": "^9.0.10",
"react-collapse": "^5.1.1", "react-collapse": "^5.1.1",
"react-color": "^2.19.3", "react-color": "^2.19.3",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
@@ -68,7 +71,6 @@
"react-i18next": "^15.7.3", "react-i18next": "^15.7.3",
"react-icon-base": "^2.1.2", "react-icon-base": "^2.1.2",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-sortable-hoc": "^2.0.0",
"reconnecting-websocket": "^4.4.0", "reconnecting-websocket": "^4.4.0",
"slugify": "^1.6.6", "slugify": "^1.6.6",
"string-hash": "^1.1.3", "string-hash": "^1.1.3",
+4 -5
View File
@@ -33,7 +33,6 @@ import { RevisionStore } from '../libs/revisions'
import LayerWatcher from '../libs/layerwatcher' import LayerWatcher from '../libs/layerwatcher'
import tokens from '../config/tokens.json' import tokens from '../config/tokens.json'
import isEqual from 'lodash.isequal' import isEqual from 'lodash.isequal'
import { SortEnd } from 'react-sortable-hoc';
import { MapOptions } from 'maplibre-gl'; import { MapOptions } from 'maplibre-gl';
import { OnStyleChangedOpts, StyleSpecificationWithId } from '../libs/definitions' import { OnStyleChangedOpts, StyleSpecificationWithId } from '../libs/definitions'
@@ -436,7 +435,7 @@ export default class App extends React.Component<any, AppState> {
if (errors.length > 0) { if (errors.length > 0) {
dirtyMapStyle = cloneDeep(newStyle); dirtyMapStyle = cloneDeep(newStyle);
errors.forEach(error => { for (const error of errors) {
const {message} = error; const {message} = error;
if (message) { if (message) {
try { try {
@@ -446,10 +445,10 @@ export default class App extends React.Component<any, AppState> {
unset(dirtyMapStyle, unsetPath); unset(dirtyMapStyle, unsetPath);
} }
catch (err) { catch (err) {
console.warn(err); console.warn(message + " " + err);
} }
} }
}); }
} }
if(newStyle.glyphs !== this.state.mapStyle.glyphs) { 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 { oldIndex, newIndex } = move;
let layers = this.state.mapStyle.layers; let layers = this.state.mapStyle.layers;
oldIndex = clamp(oldIndex, 0, layers.length-1); oldIndex = clamp(oldIndex, 0, layers.length-1);
+13 -11
View File
@@ -13,28 +13,30 @@ type FieldSourceInternalProps = {
error?: {message: string} error?: {message: string}
} & WithTranslation; } & WithTranslation;
const FieldSourceInternal: React.FC<FieldSourceInternalProps> = (props) => { const FieldSourceInternal: React.FC<FieldSourceInternalProps> = ({
const t = props.t; onChange = () => {},
sourceIds = [],
wdKey,
value,
error,
t
}) => {
return ( return (
<Block <Block
label={t('Source')} label={t('Source')}
fieldSpec={latest.layer.source} fieldSpec={latest.layer.source}
error={props.error} error={error}
data-wd-key={props.wdKey} data-wd-key={wdKey}
> >
<InputAutocomplete <InputAutocomplete
value={props.value} value={value}
onChange={props.onChange} onChange={onChange}
options={props.sourceIds?.map((src) => [src, src])} options={sourceIds?.map((src) => [src, src])}
/> />
</Block> </Block>
); );
}; };
FieldSourceInternal.defaultProps = {
onChange: () => {},
sourceIds: [],
};
const FieldSource = withTranslation()(FieldSourceInternal); const FieldSource = withTranslation()(FieldSourceInternal);
export default FieldSource; export default FieldSource;
+11 -13
View File
@@ -9,33 +9,31 @@ type FieldSourceLayerInternalProps = {
value?: string value?: string
onChange?(...args: unknown[]): unknown onChange?(...args: unknown[]): unknown
sourceLayerIds?: unknown[] sourceLayerIds?: unknown[]
isFixed?: boolean
error?: {message: string} error?: {message: string}
} & WithTranslation; } & WithTranslation;
const FieldSourceLayerInternal: React.FC<FieldSourceLayerInternalProps> = (props) => { const FieldSourceLayerInternal: React.FC<FieldSourceLayerInternalProps> = ({
const t = props.t; onChange = () => {},
sourceLayerIds = [],
value,
error,
t
}) => {
return ( return (
<Block <Block
label={t('Source Layer')} label={t('Source Layer')}
fieldSpec={latest.layer['source-layer']} fieldSpec={latest.layer['source-layer']}
data-wd-key="layer-source-layer" data-wd-key="layer-source-layer"
error={props.error} error={error}
> >
<InputAutocomplete <InputAutocomplete
value={props.value} value={value}
onChange={props.onChange} onChange={onChange}
options={props.sourceLayerIds?.map((l) => [l, l])} options={sourceLayerIds?.map((l) => [l, l])}
/> />
</Block> </Block>
); );
}; };
FieldSourceLayerInternal.defaultProps = {
onChange: () => {},
sourceLayerIds: [],
isFixed: false,
};
const FieldSourceLayer = withTranslation()(FieldSourceLayerInternal); const FieldSourceLayer = withTranslation()(FieldSourceLayerInternal);
export default FieldSourceLayer; export default FieldSourceLayer;
+19 -10
View File
@@ -22,6 +22,7 @@ import {formatLayerId} from '../libs/format';
import { WithTranslation, withTranslation } from 'react-i18next'; import { WithTranslation, withTranslation } from 'react-i18next';
import { TFunction } from 'i18next'; import { TFunction } from 'i18next';
import { NON_SOURCE_LAYERS } from '../libs/non-source-layers'; import { NON_SOURCE_LAYERS } from '../libs/non-source-layers';
import { OnMoveLayerCallback } from '../libs/definitions';
type MaputnikLayoutGroup = { type MaputnikLayoutGroup = {
id: string; id: string;
@@ -120,7 +121,7 @@ type LayerEditorInternalProps = {
spec: object spec: object
onLayerChanged(...args: unknown[]): unknown onLayerChanged(...args: unknown[]): unknown
onLayerIdChange(...args: unknown[]): unknown onLayerIdChange(...args: unknown[]): unknown
onMoveLayer(...args: unknown[]): unknown onMoveLayer: OnMoveLayerCallback
onLayerDestroy(...args: unknown[]): unknown onLayerDestroy(...args: unknown[]): unknown
onLayerCopy(...args: unknown[]): unknown onLayerCopy(...args: unknown[]): unknown
onLayerVisibilityToggle(...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 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: { delete: {
text: t("Delete"), text: t("Delete"),
handler: () => this.props.onLayerDestroy(this.props.layerIndex) handler: () => this.props.onLayerDestroy(this.props.layerIndex),
wdKey: "menu-delete-layer"
}, },
duplicate: { duplicate: {
text: t("Duplicate"), text: t("Duplicate"),
handler: () => this.props.onLayerCopy(this.props.layerIndex) handler: () => this.props.onLayerCopy(this.props.layerIndex),
wdKey: "menu-duplicate-layer"
}, },
hide: { hide: {
text: (layout.visibility === "none") ? t("Show") : t("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: { moveLayerUp: {
text: t("Move layer up"), text: t("Move layer up"),
// Not actually used...
disabled: this.props.isFirstLayer, disabled: this.props.isFirstLayer,
handler: () => this.moveLayer(-1) handler: () => this.moveLayer(-1),
wdKey: "menu-move-layer-up"
}, },
moveLayerDown: { moveLayerDown: {
text: t("Move layer down"), text: t("Move layer down"),
// Not actually used...
disabled: this.props.isLastLayer, 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) => { {Object.keys(items).map((id) => {
const item = items[id]; const item = items[id];
return <li key={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} {item.text}
</MenuItem> </MenuItem>
</li> </li>
+40 -14
View File
@@ -1,16 +1,28 @@
import React, {type JSX} from 'react' import React, {type JSX} from 'react'
import classnames from 'classnames' import classnames from 'classnames'
import lodash from 'lodash'; 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 LayerListGroup from './LayerListGroup'
import LayerListItem from './LayerListItem' import LayerListItem from './LayerListItem'
import ModalAdd from './ModalAdd' import ModalAdd from './ModalAdd'
import {SortEndHandler, SortableContainer} from 'react-sortable-hoc';
import type {LayerSpecification, SourceSpecification} from 'maplibre-gl'; import type {LayerSpecification, SourceSpecification} from 'maplibre-gl';
import generateUniqueId from '../libs/document-uid'; import generateUniqueId from '../libs/document-uid';
import { findClosestCommonPrefix, layerPrefix } from '../libs/layer'; import { findClosestCommonPrefix, layerPrefix } from '../libs/layer';
import { WithTranslation, withTranslation } from 'react-i18next'; import { WithTranslation, withTranslation } from 'react-i18next';
import { OnMoveLayerCallback } from '../libs/definitions';
type LayerListContainerProps = { type LayerListContainerProps = {
layers: LayerSpecification[] 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-group-last': idxInGroup == layers.length - 1 && layers.length > 1,
'maputnik-layer-list-item--error': !!layerError 'maputnik-layer-list-item--error': !!layerError
})} })}
index={idx}
key={layer.key} key={layer.key}
id={layer.key} id={layer.key}
layerId={layer.id} layerId={layer.id}
@@ -319,20 +330,35 @@ class LayerListContainerInternal extends React.Component<LayerListContainerInter
} }
const LayerListContainer = withTranslation()(LayerListContainerInternal); const LayerListContainer = withTranslation()(LayerListContainerInternal);
const LayerListContainerSortable = SortableContainer((props: LayerListContainerProps) => <LayerListContainer {...props} />)
type LayerListProps = LayerListContainerProps & { type LayerListProps = LayerListContainerProps & {
onMoveLayer: SortEndHandler onMoveLayer: OnMoveLayerCallback
}; };
export default class LayerList extends React.Component<LayerListProps> { const LayerList: React.FC<LayerListProps> = (props) => {
render() { const sensors = useSensors(useSensor(PointerSensor));
return <LayerListContainerSortable
{...this.props} const handleDragEnd = (event: DragEndEvent) => {
helperClass='sortableHelper' const {active, over} = event;
onSortEnd={this.props.onMoveLayer.bind(this)} if (!over) return;
useDragHandle={true}
shouldCancelStart={() => false} 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;
+81 -53
View File
@@ -1,20 +1,23 @@
import React from 'react' import React from 'react'
import classnames from 'classnames' import classnames from 'classnames'
import {MdContentCopy, MdVisibility, MdVisibilityOff, MdDelete} from 'react-icons/md' import {MdContentCopy, MdVisibility, MdVisibilityOff, MdDelete} from 'react-icons/md'
import { IconContext } from 'react-icons' import { IconContext } from 'react-icons'
import {useSortable} from '@dnd-kit/sortable'
import {CSS} from '@dnd-kit/utilities'
import IconLayer from './IconLayer' import IconLayer from './IconLayer'
import {SortableElement, SortableHandle} from 'react-sortable-hoc'
type DraggableLabelProps = { type DraggableLabelProps = {
layerId: string layerId: string
layerType: string layerType: string
dragAttributes?: React.HTMLAttributes<HTMLElement>
dragListeners?: React.HTMLAttributes<HTMLElement>
}; };
const DraggableLabel = SortableHandle((props: DraggableLabelProps) => { const DraggableLabel: React.FC<DraggableLabelProps> = (props) => {
return <div className="maputnik-layer-list-item-handle"> const {dragAttributes, dragListeners} = props;
return <div className="maputnik-layer-list-item-handle" {...dragAttributes} {...dragListeners}>
<IconLayer <IconLayer
className="layer-handle__icon" className="layer-handle__icon"
type={props.layerType} type={props.layerType}
@@ -23,7 +26,7 @@ const DraggableLabel = SortableHandle((props: DraggableLabelProps) => {
{props.layerId} {props.layerId}
</button> </button>
</div> </div>
}); };
type IconActionProps = { type IconActionProps = {
action: string action: string
@@ -82,55 +85,80 @@ type LayerListItemProps = {
onLayerVisibilityToggle?(...args: unknown[]): unknown onLayerVisibilityToggle?(...args: unknown[]): unknown
}; };
class LayerListItem extends React.Component<LayerListItemProps> { const LayerListItem = React.forwardRef<HTMLLIElement, LayerListItemProps>((props, ref) => {
static defaultProps = { const {
isSelected: false, isSelected = false,
visibility: 'visible', visibility = 'visible',
onLayerCopy: () => {}, onLayerCopy = () => {},
onLayerDestroy: () => {}, onLayerDestroy = () => {},
onLayerVisibilityToggle: () => {}, onLayerVisibilityToggle = () => {},
} } = props;
render() { const {
const visibilityAction = this.props.visibility === 'visible' ? 'show' : 'hide'; attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({id: props.layerId});
return <IconContext.Provider value={{size: '14px'}}> const style = {
<li transform: CSS.Transform.toString(transform),
id={this.props.id} transition,
key={this.props.layerId} opacity: isDragging ? 0.5 : 1,
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 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;
-1
View File
@@ -175,7 +175,6 @@ class ModalAddInternal extends React.Component<ModalAddInternalProps, ModalAddSt
} }
{!NON_SOURCE_LAYERS.includes(this.state.type) && {!NON_SOURCE_LAYERS.includes(this.state.type) &&
<FieldSourceLayer <FieldSourceLayer
isFixed={true}
sourceLayerIds={layers} sourceLayerIds={layers}
value={this.state['source-layer']} value={this.state['source-layer']}
onChange={(v: string) => this.setState({ 'source-layer': v })} onChange={(v: string) => this.setState({ 'source-layer': v })}
+2
View File
@@ -10,6 +10,8 @@ export type OnStyleChangedOpts = {
export type OnStyleChangedCallback = (newStyle: StyleSpecificationWithId, opts: OnStyleChangedOpts={}) => void; export type OnStyleChangedCallback = (newStyle: StyleSpecificationWithId, opts: OnStyleChangedOpts={}) => void;
export type OnMoveLayerCallback = (move: {oldIndex: number; newIndex: number}) => void;
export interface IStyleStore { export interface IStyleStore {
getLatestStyle(): Promise<StyleSpecificationWithId>; getLatestStyle(): Promise<StyleSpecificationWithId>;
save(mapStyle: StyleSpecificationWithId): StyleSpecificationWithId; save(mapStyle: StyleSpecificationWithId): StyleSpecificationWithId;