Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion web/client/components/mapviews/MapViewItem.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ const drop = dropTarget('option',
hover: (props, monitor) => {
const item = monitor.getItem();
const { id, index, onMove = () => { } } = props;
const node = document.querySelector(`[data-id=item-${formatDataId(id)}]`);
const node = document.querySelector(`[data-id="item-${formatDataId(id)}"]`);

if (!node?.getBoundingClientRect) {
return null;
Expand Down
148 changes: 148 additions & 0 deletions web/client/components/mapviews/__tests__/MapViewItem-test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/*
* Copyright 2026, GeoSolutions Sas.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree.
*/

import React from 'react';
import ReactDOM from 'react-dom';
import expect from 'expect';
import TestUtils from 'react-dom/test-utils';
import { DragDropContext as dragDropContext } from 'react-dnd';
import testBackend from 'react-dnd-test-backend';

import MapViewItem from '../MapViewItem';

const DndMapViewItem = dragDropContext(testBackend)(({ children, ...props }) => (
<ul><MapViewItem {...props} /></ul>
));

describe('MapViewItem component', () => {
beforeEach((done) => {
document.body.innerHTML = '<div id="container"></div>';
setTimeout(done);
});

afterEach((done) => {
ReactDOM.unmountComponentAtNode(document.getElementById("container"));
document.body.innerHTML = '';
setTimeout(done);
});

it('should render with default props', () => {
ReactDOM.render(<DndMapViewItem id="view-1" title="Test View" />, document.getElementById("container"));
const item = document.querySelector('.ms-map-views-item');
expect(item).toBeTruthy();
});

it('should display the title', () => {
ReactDOM.render(<DndMapViewItem id="view-1" title="My View" />, document.getElementById("container"));
const titleNode = document.querySelector('.ms-map-views-item-title');
expect(titleNode).toBeTruthy();
expect(titleNode.innerHTML).toBe('My View');
});

it('should set correct data-id attribute', () => {
ReactDOM.render(<DndMapViewItem id="view-1" title="Test" />, document.getElementById("container"));
const item = document.querySelector('.ms-map-views-item');
expect(item).toBeTruthy();
expect(item.getAttribute('data-id')).toBe('item-view-1');
});

it('should sanitize special characters in data-id', () => {
ReactDOM.render(<DndMapViewItem id="view.with.dots" title="Test" />, document.getElementById("container"));
const item = document.querySelector('.ms-map-views-item');
expect(item).toBeTruthy();
expect(item.getAttribute('data-id')).toBe('item-view-with-dots');
});

it('should add selected class when selected is true', () => {
ReactDOM.render(<DndMapViewItem id="view-1" title="Test" selected />, document.getElementById("container"));
const item = document.querySelector('.ms-map-views-item.selected');
expect(item).toBeTruthy();
});

it('should not add selected class when selected is false', () => {
ReactDOM.render(<DndMapViewItem id="view-1" title="Test" selected={false} />, document.getElementById("container"));
const item = document.querySelector('.ms-map-views-item');
expect(item).toBeTruthy();
expect(item.className.indexOf('selected')).toBe(-1);
});

it('should set opacity to 1 when not dragging', () => {
ReactDOM.render(<DndMapViewItem id="view-1" title="Test" />, document.getElementById("container"));
const item = document.querySelector('.ms-map-views-item');
expect(item).toBeTruthy();
expect(item.style.opacity).toBe('1');
});

it('should call onSelect when the item is clicked', () => {
const actions = { onSelect: () => {} };
const spy = expect.spyOn(actions, 'onSelect');
ReactDOM.render(<DndMapViewItem id="view-1" title="Test" onSelect={actions.onSelect} />, document.getElementById("container"));
const item = document.querySelector('.ms-map-views-item');
expect(item).toBeTruthy();
TestUtils.Simulate.click(item);
expect(spy).toHaveBeenCalled();
});

it('should render remove button when onRemove is provided', () => {
ReactDOM.render(<DndMapViewItem id="view-1" title="Test" onRemove={() => {}} />, document.getElementById("container"));
const removeButton = document.querySelector('.square-button');
expect(removeButton).toBeTruthy();
});

it('should not render remove button when onRemove is not provided', () => {
ReactDOM.render(<DndMapViewItem id="view-1" title="Test" />, document.getElementById("container"));
const removeButton = document.querySelector('.square-button');
expect(removeButton).toBeFalsy();
});

it('should call onRemove when remove button is clicked', () => {
const actions = { onRemove: () => {} };
const spy = expect.spyOn(actions, 'onRemove');
ReactDOM.render(<DndMapViewItem id="view-1" title="Test" onRemove={actions.onRemove} />, document.getElementById("container"));
const removeButton = document.querySelector('.square-button');
expect(removeButton).toBeTruthy();
TestUtils.Simulate.click(removeButton);
expect(spy).toHaveBeenCalled();
});

it('should use primary style for remove button when selected', () => {
ReactDOM.render(<DndMapViewItem id="view-1" title="Test" selected onRemove={() => {}} />, document.getElementById("container"));
const removeButton = document.querySelector('.square-button');
expect(removeButton).toBeTruthy();
expect(removeButton.className.indexOf('btn-primary') !== -1).toBe(true);
});

it('should use default style for remove button when not selected', () => {
ReactDOM.render(<DndMapViewItem id="view-1" title="Test" selected={false} onRemove={() => {}} />, document.getElementById("container"));
const removeButton = document.querySelector('.square-button');
expect(removeButton).toBeTruthy();
expect(removeButton.className.indexOf('btn-default') !== -1).toBe(true);
});

it('should render grab handle when isSortable is true', () => {
ReactDOM.render(<DndMapViewItem id="view-1" title="Test" isSortable />, document.getElementById("container"));
const handle = document.querySelector('.grab-handle');
expect(handle).toBeTruthy();
});

it('should not render grab handle when isSortable is false', () => {
ReactDOM.render(<DndMapViewItem id="view-1" title="Test" isSortable={false} />, document.getElementById("container"));
const handle = document.querySelector('.grab-handle');
expect(handle).toBeFalsy();
});

it('should produce correct data-id for UUID-style ids', () => {
ReactDOM.render(<DndMapViewItem id="2f12a630-31e1-11f1-a710-3f4fb2315510" title="Test" />, document.getElementById("container"));
const item = document.querySelector('.ms-map-views-item');
expect(item).toBeTruthy();
expect(item.getAttribute('data-id')).toBe('item-2f12a630-31e1-11f1-a710-3f4fb2315510');
const found = document.querySelector(`[data-id="${item.getAttribute('data-id')}"]`);
expect(found).toBeTruthy();
expect(found).toBe(item);
});
});
8 changes: 4 additions & 4 deletions web/client/plugins/TOC/components/DropNode.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ import { DropTarget as dropTarget } from 'react-dnd';

const ITEM_KEY = 'node';

const formatDataId = (_id = '', position, lastId) => {
export const formatDataId = (_id = '', position, lastId) => {
let id = _id;
if (lastId) {
// ensure to get the latest id from groups
const parts = _id.split('.');
id = parts[parts.length - 1];
}
return `node-${id.replace(/\.|\:| /g, '-')}${position ? `-${position}` : ''}`;
return `node-${id.replace(/[^a-zA-Z0-9_-]/g, '-')}${position ? `-${position}` : ''}`;
};

const computeSorting = (props, monitor) => {
Expand All @@ -32,8 +32,8 @@ const computeSorting = (props, monitor) => {
}
const rootParentId = containerNode.getAttribute('data-root-parent-id');
const hoverTargetId = id || rootParentId;
const hoverNode = containerNode.querySelector(`[data-id=${formatDataId(hoverTargetId, position, true)}]`);
const dragNode = containerNode.querySelector(`[data-id=${formatDataId(dragItem.id || rootParentId, dragItem.position, true)}]`);
const hoverNode = containerNode.querySelector(`[data-id="${formatDataId(hoverTargetId, position, true)}"]`);
const dragNode = containerNode.querySelector(`[data-id="${formatDataId(dragItem.id || rootParentId, dragItem.position, true)}"]`);

if (!hoverNode?.getBoundingClientRect || !dragNode?.getBoundingClientRect) {
return null;
Expand Down
190 changes: 190 additions & 0 deletions web/client/plugins/TOC/components/__tests__/DropNode-test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
/*
* Copyright 2026, GeoSolutions Sas.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree.
*/

import expect from 'expect';
import React from 'react';
import ReactDOM from 'react-dom';
import { DragDropContext as dragDropContext } from 'react-dnd';
import testBackend from 'react-dnd-test-backend';

import DropNode, { formatDataId } from '../DropNode';

describe('DropNode component', () => {
beforeEach((done) => {
document.body.innerHTML = '<div id="container"></div>';
setTimeout(done);
});

afterEach((done) => {
ReactDOM.unmountComponentAtNode(document.getElementById("container"));
document.body.innerHTML = '';
setTimeout(done);
});

it('renders children directly when sortable is false', () => {
ReactDOM.render(
<DropNode sortable={false} id="layer01">
<span className="test-child">content</span>
</DropNode>,
document.getElementById("container")
);
const child = document.querySelector('.test-child');
expect(child).toBeTruthy();
expect(child.innerHTML).toBe('content');
const dropNode = document.querySelector('.ms-drop-node');
expect(dropNode).toBeFalsy();
});

it('renders Drop wrapper with correct data-id when sortable is true', () => {
const WrappedDropNode = dragDropContext(testBackend)(
() => (
<DropNode sortable id="layer01" sort={{}}>
<span className="test-child">content</span>
</DropNode>
)
);
ReactDOM.render(<WrappedDropNode />, document.getElementById("container"));
const dropNode = document.querySelector('.ms-drop-node');
expect(dropNode).toBeTruthy();
expect(dropNode.getAttribute('data-id')).toBe('node-layer01');
expect(dropNode.getAttribute('data-node-id')).toBe('layer01');
});

it('renders correct data-id for ids with special characters', () => {
const WrappedDropNode = dragDropContext(testBackend)(
() => (
<DropNode sortable id="Wells by Status (Active)" sort={{}}>
<span>content</span>
</DropNode>
)
);
ReactDOM.render(<WrappedDropNode />, document.getElementById("container"));
const dropNode = document.querySelector('.ms-drop-node');
expect(dropNode).toBeTruthy();
const dataId = dropNode.getAttribute('data-id');
expect(dataId).toBe('node-Wells-by-Status--Active-');
const found = document.querySelector(`[data-id="${dataId}"]`);
expect(found).toBeTruthy();
expect(found).toBe(dropNode);
});

it('includes position in data-id', () => {
const WrappedDropNode = dragDropContext(testBackend)(
() => (
<DropNode sortable id="layer01" position="before" sort={{}}>
<span>content</span>
</DropNode>
)
);
ReactDOM.render(<WrappedDropNode />, document.getElementById("container"));
const dropNode = document.querySelector('.ms-drop-node');
expect(dropNode).toBeTruthy();
expect(dropNode.getAttribute('data-id')).toBe('node-layer01-before');
});

it('applies nodeType as className', () => {
const WrappedDropNode = dragDropContext(testBackend)(
() => (
<DropNode sortable id="layer01" nodeType="layers" sort={{}}>
<span>content</span>
</DropNode>
)
);
ReactDOM.render(<WrappedDropNode />, document.getElementById("container"));
const dropNode = document.querySelector('.ms-drop-node.layers');
expect(dropNode).toBeTruthy();
});

describe('formatDataId', () => {

it('returns prefixed id for a simple string', () => {
expect(formatDataId('layer01')).toBe('node-layer01');
});

it('replaces dots with dashes', () => {
expect(formatDataId('group.layer01')).toBe('node-group-layer01');
});

it('replaces colons with dashes', () => {
expect(formatDataId('ns:layer01')).toBe('node-ns-layer01');
});

it('replaces spaces with dashes', () => {
expect(formatDataId('my layer')).toBe('node-my-layer');
});

it('replaces parentheses with dashes', () => {
expect(formatDataId('Wells (Active)')).toBe('node-Wells--Active-');
});

it('replaces brackets with dashes', () => {
expect(formatDataId('layer[0]')).toBe('node-layer-0-');
});

it('replaces double quotes with dashes', () => {
expect(formatDataId('12" pipe')).toBe('node-12--pipe');
});

it('replaces single quotes with dashes', () => {
expect(formatDataId("Well's")).toBe('node-Well-s');
});

it('preserves underscores and hyphens', () => {
expect(formatDataId('my_layer-01')).toBe('node-my_layer-01');
});

it('handles multiple special characters together', () => {
expect(formatDataId('odnr_geology:Wells by Status (Active)'))
.toBe('node-odnr_geology-Wells-by-Status--Active-');
});

it('appends position when provided', () => {
expect(formatDataId('layer01', 'before')).toBe('node-layer01-before');
expect(formatDataId('layer01', 'after')).toBe('node-layer01-after');
});

it('does not append position when position is falsy', () => {
expect(formatDataId('layer01', null)).toBe('node-layer01');
expect(formatDataId('layer01', '')).toBe('node-layer01');
expect(formatDataId('layer01', undefined)).toBe('node-layer01');
});

it('extracts last part of dot-separated id when lastId is true', () => {
expect(formatDataId('parentGroup.childGroup', null, true)).toBe('node-childGroup');
expect(formatDataId('a.b.c', null, true)).toBe('node-c');
});

it('uses full id when lastId is false', () => {
expect(formatDataId('parentGroup.childGroup', null, false)).toBe('node-parentGroup-childGroup');
});

it('combines lastId and position', () => {
expect(formatDataId('parent.child', 'before', true)).toBe('node-child-before');
});

it('produces data-id values usable in querySelector with special characters', () => {
const ids = [
'Wells by Status (Active)',
'odnr_geology:Wells[0]',
'12" pipe',
"Well's layer",
'layer+name~test'
];
ids.forEach(rawId => {
const dataId = formatDataId(rawId);
const div = document.createElement('div');
div.setAttribute('data-id', dataId);
document.body.appendChild(div);
const found = document.querySelector(`[data-id="${dataId}"]`);
expect(found).toBeTruthy();
expect(found).toBe(div);
document.body.removeChild(div);
});
});
});
});
Loading