diff --git a/src/LayerTree/LayerTree.example.md b/src/LayerTree/LayerTree.example.md index 57acd1a398..98d814c582 100644 --- a/src/LayerTree/LayerTree.example.md +++ b/src/LayerTree/LayerTree.example.md @@ -2,6 +2,8 @@ This example demonstrates the LayerTree. ```jsx import LayerTree from '@terrestris/react-geo/dist/LayerTree/LayerTree'; +import MapComponent from '@terrestris/react-util/dist/Components/MapComponent/MapComponent'; +import MapContext from '@terrestris/react-util/dist/Context/MapContext/MapContext'; import OlLayerGroup from 'ol/layer/Group'; import OlLayerTile from 'ol/layer/Tile'; import OlMap from 'ol/Map'; @@ -11,102 +13,156 @@ import OlSourceTileWMS from 'ol/source/TileWMS'; import OlView from 'ol/View'; import * as React from 'react'; -class LayerTreeExample extends React.Component { - - constructor(props) { - - super(props); - - this.mapDivId = `map-${Math.random()}`; - - this.layerGroup = new OlLayerGroup({ - name: 'Layergroup', - layers: [ - new OlLayerTile({ - name: 'OSM-Overlay-WMS', - minResolution: 0, - maxResolution: 200, - source: new OlSourceTileWMS({ - url: 'https://ows.terrestris.de/osm/service', - params: { - LAYERS: 'OSM-Overlay-WMS' - } - }) - }), - new OlLayerTile({ - name: 'SRTM30-Contour', - minResolution: 0, - maxResolution: 10, - source: new OlSourceTileWMS({ - url: 'https://ows.terrestris.de/osm/service', - params: { - LAYERS: 'SRTM30-Contour' - } - }) +const LayerTreeExample = () => { + + const layerGroup = new OlLayerGroup({ + properties: { + name: 'Layergroup' + }, + layers: [ + new OlLayerTile({ + properties: { + name: 'OSM-Overlay-WMS' + }, + minResolution: 0, + maxResolution: 200, + source: new OlSourceTileWMS({ + url: 'https://ows.terrestris.de/osm/service', + params: { + LAYERS: 'OSM-Overlay-WMS' + } + }) + }), + new OlLayerTile({ + properties: { + name: 'SRTM30-Contour' + }, + minResolution: 0, + maxResolution: 10, + visible: false, + source: new OlSourceTileWMS({ + url: 'https://ows.terrestris.de/osm/service', + params: { + LAYERS: 'SRTM30-Contour' + } + }) + }), + new OlLayerTile({ + properties: { + name: 'SRTM30-Colored-Hillshade' + }, + minResolution: 0, + maxResolution: 10, + visible: false, + source: new OlSourceTileWMS({ + url: 'https://ows.terrestris.de/osm/service', + params: { + LAYERS: 'SRTM30-Colored-Hillshade' + } }) - ] - }); - - this.map = new OlMap({ - layers: [ - new OlLayerTile({ - name: 'OSM', - source: new OlSourceOSM() - }), - this.layerGroup - ], - view: new OlView({ - center: fromLonLat([12.924, 47.551]), - zoom: 13 }) - }); - } - - componentDidMount() { - this.map.setTarget(this.mapDivId); - } - - render() { - return ( -
-
+ + + {'Please note that the layers have resolution restrictions, please ' + + ' zoom in and out to see how the trees react to this.'} +
+ {'Autoconfigured with topmost LayerGroup of passed map:'} + + - {'Please note that the layers have resolution restrictions, please ' + - ' zoom in and out to see how the trees react to this.'} -
- {'Autoconfigured with topmost LayerGroup of passed map:'} - - - -
+
-
- {'A LayerTree configured with concrete layerGroup:'} +
+ {'A LayerTree configured with concrete layerGroup:'} - -
+ +
-
- {'A LayerTree with a filterFunction (The OSM layer is filtered out):'} +
+ {'A LayerTree with a filterFunction (The OSM layer is filtered out):'} - layer.get('name') !== 'OSM'} - /> -
+ layer.get('name') !== 'OSM'} + className="bottom" + toggleOnClick={true} + />
- ); - } + + ); } diff --git a/src/LayerTree/LayerTree.less b/src/LayerTree/LayerTree.less new file mode 100644 index 0000000000..3f5232748b --- /dev/null +++ b/src/LayerTree/LayerTree.less @@ -0,0 +1,9 @@ +.react-geo-layertree { + .out-of-range { + opacity: 0.5; + } + + .ant-tree-node-content-wrapper { + pointer-events: none; + } +} diff --git a/src/LayerTree/LayerTree.spec.tsx b/src/LayerTree/LayerTree.spec.tsx index ee6d4c8400..94007076bd 100644 --- a/src/LayerTree/LayerTree.spec.tsx +++ b/src/LayerTree/LayerTree.spec.tsx @@ -1,17 +1,15 @@ -import { getUid } from 'ol'; -import OlCollection from 'ol/Collection'; +import { act,fireEvent, render, screen } from '@testing-library/react'; import OlLayerBase from 'ol/layer/Base'; import OlLayerGroup from 'ol/layer/Group'; import OlLayerTile from 'ol/layer/Tile'; import OlMap from 'ol/Map'; -import * as OlObservable from 'ol/Observable'; import OlSourceTileWMS from 'ol/source/TileWMS'; import * as React from 'react'; -import { - act -} from 'react-dom/test-utils'; -import TestUtil from '../Util/TestUtil'; +import TestUtil from '../Util/TestUtil';; +import { renderInMapContext } from '@terrestris/react-util/dist/Util/rtlTestUtils'; +import userEvent from '@testing-library/user-event'; + import LayerTree from './LayerTree'; describe('', () => { @@ -28,6 +26,8 @@ describe('', () => { properties: { name: 'layer1' }, + minResolution: 10, + maxResolution: 1000, source: layerSource1 }); const layerSource2 = new OlSourceTileWMS(); @@ -67,605 +67,468 @@ describe('', () => { TestUtil.removeMap(map); }); - it('is defined', () => { - expect(LayerTree).not.toBeUndefined(); - }); - it('can be rendered', () => { - const wrapper = TestUtil.mountComponent(LayerTree, {map}); - expect(wrapper).not.toBeUndefined(); - }); + const { + container + } = render(); - it('layergroup taken from map if not provided', () => { - const wrapper = TestUtil.mountComponent(LayerTree, {map}); - expect(wrapper.state('layerGroup')).toBe(map.getLayerGroup()); + expect(container).toBeVisible(); }); - it('unmount removes listeners', () => { - // @ts-ignore - OlObservable.unByKey = jest.fn(); - const wrapper = TestUtil.mountComponent(LayerTree, {map}); - const olListenerKeys = (wrapper.instance() as LayerTree).olListenerKeys; - wrapper.unmount(); - expect(OlObservable.unByKey).toHaveBeenCalled(); - expect(OlObservable.unByKey).toHaveBeenCalledWith(olListenerKeys); - }); + it('sets the layer name as title per default', () => { + renderInMapContext(map, + + ); - it('CWR with new layerGroup rebuild listeners and treenodes ', () => { - const props = { - layerGroup, - map - }; - const wrapper = TestUtil.mountComponent(LayerTree, props); + const expectedNodeTitles = [ + 'layer1', + 'layer2', + 'layerSubGroup' + ]; - const subLayer = new OlLayerTile({ - properties: { - name: 'subLayer' - }, - source: new OlSourceTileWMS() - }); - const nestedLayerGroup = new OlLayerGroup({ - properties: { - name: 'nestedLayerGroup' - }, - layers: [subLayer] + expectedNodeTitles.forEach(expectedNodeTitle => { + const node = screen.queryByText(expectedNodeTitle); + expect(node).toBeInTheDocument(); }); - expect((wrapper.instance() as LayerTree).olListenerKeys).toHaveLength(10); - wrapper.setProps({ - layerGroup: nestedLayerGroup + const unexpectedNodeTitles = [ + // Not visible since not expanded. + 'layer3', + // Not visible since it's the root node. + 'layerGroup' + ]; + + unexpectedNodeTitles.forEach(unexpectedNodeTitle => { + const node = screen.queryByText(unexpectedNodeTitle); + expect(node).not.toBeInTheDocument(); }); - expect((wrapper.instance() as LayerTree).olListenerKeys).toHaveLength(4); }); - describe(' creation', () => { + it('accepts a layer group instead of getting all layers from the map directly', () => { + renderInMapContext(map, + + ); - it('adds a for every child', () => { - const props = { - layerGroup, - map - }; - const wrapper = TestUtil.mountComponent(LayerTree, props); - const treeNodes = wrapper.find('.ant-tree-treenode'); + const expectedNodeTitles = [ + 'layer3' + ]; - expect(treeNodes).toHaveLength(4); + expectedNodeTitles.forEach(expectedNodeTitle => { + const node = screen.queryByText(expectedNodeTitle); + expect(node).toBeInTheDocument(); }); - // TODO This test could be better if the TreeNodes where iterable, but they - // are not. See comment below. - // it('can handle nested `ol.layer.group`s', () => { - // const props = { - // layerGroup, - // map - // }; - // const subLayer = new OlLayerTile({ - // name: 'subLayer', - // source: new OlSourceTileWMS() - // }); - // const nestedLayerGroup = new OlLayerGroup({ - // name: 'nestedLayerGroup', - // layers: [subLayer] - // }); - // layerGroup.getLayers().push(nestedLayerGroup); - - // const wrapper = TestUtil.mountComponent(LayerTree, props); - // const treeNodes = wrapper.find('.ant-tree-treenode'); - - // const groupNode = treeNodes.at(0); - // const subNode = groupNode.props().children[0]; - - // expect(subNode.props.title).toBe(subLayer.get('name')); - // }); - - it('can handle a replacement of layergroups `ol.Collection`', () => { - const props = { - map - }; - const subLayer = new OlLayerTile({ - properties: { - name: 'subLayer' - }, - source: new OlSourceTileWMS() - }); - const wrapper = TestUtil.mountComponent(LayerTree, props); - const rebuildSpy = jest.spyOn((wrapper.instance() as LayerTree), 'rebuildTreeNodes'); - map.getLayerGroup().setLayers(new OlCollection([subLayer])); - expect(rebuildSpy).toHaveBeenCalled(); - rebuildSpy.mockRestore(); + const unexpectedNodeTitles = [ + // Not visible since not contained in layerSubGroup + 'layer1', + 'layer2', + 'layerGroup', + // Not visible since it's the root node. + 'layerSubGroup' + ]; + + unexpectedNodeTitles.forEach(unexpectedNodeTitle => { + const node = screen.queryByText(unexpectedNodeTitle); + expect(node).not.toBeInTheDocument(); }); + }); - it('sets the layer name as title per default', () => { - const props = { - layerGroup, - map - }; - const wrapper = TestUtil.mountComponent(LayerTree, props); - const treeNodes = wrapper.find('LayerTreeNode'); - treeNodes.forEach((node: any, index: number) => { - const reverseIndex = treeNodes.length - (index + 1); - const layer = layerGroup.getLayers().item(reverseIndex); - expect(node.props().title).toBe(layer.get('name')); - }); - }); + it('removes all registered view listeners on unmount', () => { + expect(map.getListeners('moveend')).toBeUndefined(); - it('accepts a custom title renderer function', () => { - const nodeTitleRenderer = function(layer: OlLayerBase) { - return ( - - - {layer.get('name')} - - - - ); - }; - const props = { - layerGroup, - map, - nodeTitleRenderer - }; - const wrapper = TestUtil.mountComponent(LayerTree, props); - const treeNodes = wrapper.find('LayerTreeNode'); - - treeNodes.forEach((node: any, index: number) => { - const reverseIndex = treeNodes.length - (index + 1); - const layer = layerGroup.getLayers().item(reverseIndex); - expect(node.find('span.span-1').length).toBe(1); - expect(node.find('span.sub-span-1').length).toBe(1); - expect(node.find('span.sub-span-1').props().children).toBe(layer.get('name')); - expect(node.find('span.sub-span-2').length).toBe(1); - }); - }); + const { + unmount + } = renderInMapContext(map, + + ); - it('can filter layers if a filterFunction is given', () => { - const filterFunction = function(layer: OlLayerBase) { - return layer.get('name') !== 'layer1'; - }; - const props = { - layerGroup, - map, - filterFunction - }; - const wrapper = TestUtil.mountComponent(LayerTree, props); - const treeNodes = wrapper.find('.ant-tree-treenode'); - - expect(treeNodes.length).toBe(3); - }); + expect(map.getView().getListeners('change:resolution')?.length).toBe(1); - it('sets the right keys for the layers', () => { - const props = { - layerGroup, - map - }; - const wrapper = TestUtil.mountComponent(LayerTree, props); - const treeNodes = wrapper.find('LayerTreeNode'); - - treeNodes.forEach((node: any, index: number) => { - const reverseIndex = treeNodes.length - (index + 1); - const layer = layerGroup.getLayers().item(reverseIndex); - expect(node.props().eventKey).toBe(getUid(layer)); - }); - }); + unmount(); - it('sets visible layers as checked', () => { - const props = { - layerGroup, - map - }; - const wrapper = TestUtil.mountComponent(LayerTree, props); - const treeNodes = wrapper.find('LayerTreeNode'); - - treeNodes.forEach((node: any, index: number) => { - const reverseIndex = treeNodes.length - (index + 1); - const layer = layerGroup.getLayers().item(reverseIndex); - expect(layer.getVisible()).toBe(node.props().checked); - }); - }); + expect(map.getView().getListeners('change:resolution')).toBeUndefined(); + }); - describe('#treeNodeFromLayer', () => { + it('removes all registered layer(-group) listeners on unmount', () => { + expect(layer1.getListeners('change:visible')).toBeUndefined(); + expect(layer2.getListeners('change:visible')).toBeUndefined(); + expect(layer3.getListeners('change:visible')).toBeUndefined(); - it('returns a LayerTreeNode when called with a layer', () => { - const props = { - layerGroup, - map - }; - layerGroup.setVisible(false); + // The layer group has registered a default listener. + expect(layerGroup.getLayers().getListeners('add')?.length).toBe(1); + expect(layerGroup.getLayers().getListeners('remove')?.length).toBe(1); + expect(layerGroup.getListeners('change:layers')?.length).toBe(1); - const wrapper = TestUtil.mountComponent(LayerTree, props); - const treeNode = (wrapper.instance() as LayerTree).treeNodeFromLayer(layer1); + expect(layerSubGroup.getLayers().getListeners('add')?.length).toBe(1); + expect(layerSubGroup.getLayers().getListeners('remove')?.length).toBe(1); + expect(layerSubGroup.getListeners('change:layers')?.length).toBe(1); - expect(treeNode.props.title).toBe(layer1.get('name')); - expect(treeNode.key).toBe(getUid(layer1)); - }); - }); + const { + unmount + } = renderInMapContext(map, + + ); + + expect(layer1.getListeners('change:visible')?.length).toBe(1); + expect(layer2.getListeners('change:visible')?.length).toBe(1); + expect(layer3.getListeners('change:visible')?.length).toBe(1); + + expect(layerGroup.getLayers().getListeners('add')?.length).toBe(2); + expect(layerGroup.getLayers().getListeners('remove')?.length).toBe(2); + expect(layerGroup.getListeners('change:layers')?.length).toBe(2); + + expect(layerSubGroup.getLayers().getListeners('add')?.length).toBe(2); + expect(layerSubGroup.getLayers().getListeners('remove')?.length).toBe(2); + expect(layerSubGroup.getListeners('change:layers')?.length).toBe(2); + + unmount(); + + expect(layer1.getListeners('change:visible')).toBeUndefined(); + expect(layer2.getListeners('change:visible')).toBeUndefined(); + expect(layer3.getListeners('change:visible')).toBeUndefined(); + + expect(layerGroup.getLayers().getListeners('add')?.length).toBe(1); + expect(layerGroup.getLayers().getListeners('remove')?.length).toBe(1); + expect(layerGroup.getListeners('change:layers')?.length).toBe(1); + + expect(layerSubGroup.getLayers().getListeners('add')?.length).toBe(1); + expect(layerSubGroup.getLayers().getListeners('remove')?.length).toBe(1); + expect(layerSubGroup.getListeners('change:layers')?.length).toBe(1); }); - describe('#onCheck', () => { - it('sets the correct visibility to the layer from the checked state', () => { - const props = { - layerGroup, - map - }; - const wrapper = TestUtil.mountComponent(LayerTree, props); - const treeNodes = wrapper.find('LayerTreeNode'); - - - treeNodes.forEach((node: any, index: number) => { - const reverseIndex = treeNodes.length - (index + 1); - const layer = layerGroup.getLayers().item(reverseIndex); - const checkBox = node.find('.ant-tree-checkbox'); - const wasVisible = layer.getVisible(); - checkBox.simulate('click'); - expect(wasVisible).toBe(!layer.getVisible()); - }); + it('accepts a custom title renderer function', () => { + const nodeTitleRenderer = (layer: OlLayerBase) => { + return {`Custom-${layer.get('name')}`}; + }; + + renderInMapContext(map, + + ); + + const expectedNodeTitles = [ + 'Custom-layer3' + ]; + + expectedNodeTitles.forEach(expectedNodeTitle => { + const node = screen.queryByText(expectedNodeTitle); + expect(node).toBeInTheDocument(); }); }); - describe('event handling', () => { - - it('sets checked state corresponding to layer.setVisible', () => { - const props = { - layerGroup, - map - }; - const wrapper = TestUtil.mountComponent(LayerTree, props); - let treeNode = wrapper.find('.ant-tree-treenode').at(3); - - expect(treeNode.find('.ant-tree-checkbox-checked').length).toEqual(1); - act(() => { - layer1.setVisible(false); - }); - wrapper.update(); - treeNode = wrapper.find('.ant-tree-treenode').at(3); - expect(treeNode.find('.ant-tree-checkbox-checked').length).toEqual(0); - act(() => { - layer1.setVisible(true); - }); - wrapper.update(); - treeNode = wrapper.find('.ant-tree-treenode').at(3); - expect(treeNode.find('.ant-tree-checkbox-checked').length).toEqual(1); - }); + it('accepts a filterFunction to filter the layers in the tree', () => { + const filterFunction = (layer: OlLayerBase) => { + return layer.get('name') === 'layer1'; + }; - it('triggers tree rebuild on nodeTitleRenderer changes', () => { - const exampleNodeTitleRenderer = function(layer: OlLayerBase) { - return ( - - {layer.get('name')} - - ); - }; - - const props = { - layerGroup, - map - }; - const wrapper = TestUtil.mountComponent(LayerTree, props); - const rebuildSpy = jest.spyOn((wrapper.instance() as LayerTree), 'rebuildTreeNodes'); - - wrapper.setProps({ - nodeTitleRenderer: exampleNodeTitleRenderer - }); - expect(rebuildSpy).toHaveBeenCalledTimes(1); - - wrapper.setProps({ - nodeTitleRenderer: null - }); - expect(rebuildSpy).toHaveBeenCalledTimes(2); - - rebuildSpy.mockRestore(); - }); + renderInMapContext(map, + + ); + }); + + it('sets visible layers as checked initially', () => { + renderInMapContext(map, + + ); + + const layer1Span = screen.queryByText('layer1'); + const layer2Span = screen.queryByText('layer2'); + + // eslint-disable-next-line testing-library/no-node-access + expect(layer1Span?.parentNode?.parentNode).toHaveClass('ant-tree-treenode-checkbox-checked'); + // eslint-disable-next-line testing-library/no-node-access + expect(layer2Span?.parentNode?.parentNode).not.toHaveClass('ant-tree-treenode-checkbox-checked'); + }); + + it('sets the layers visibility on check', async () => { + renderInMapContext(map, + + ); + + const layer1Span = screen.getByText('layer1'); + const layer2Span = screen.getByText('layer2'); + + // eslint-disable-next-line testing-library/no-node-access + expect(layer1Span?.parentNode?.parentNode).toHaveClass('ant-tree-treenode-checkbox-checked'); + // eslint-disable-next-line testing-library/no-node-access + expect(layer2Span?.parentNode?.parentNode).not.toHaveClass('ant-tree-treenode-checkbox-checked'); - it('triggers tree rebuild on layer add', () => { - const props = { - layerGroup, - map - }; - const layer = new OlLayerTile({ - source: new OlSourceTileWMS() - }); - const wrapper = TestUtil.mountComponent(LayerTree, props); - const rebuildSpy = jest.spyOn((wrapper.instance() as LayerTree), 'rebuildTreeNodes'); - - layerGroup.getLayers().push(layer); - expect(rebuildSpy).toHaveBeenCalled(); - - rebuildSpy.mockRestore(); + expect(layer1.getVisible()).toBe(true); + expect(layer2.getVisible()).toBe(false); + + await userEvent.click(layer1Span); + await userEvent.click(layer2Span); + + // eslint-disable-next-line testing-library/no-node-access + expect(layer1Span?.parentNode?.parentNode).not.toHaveClass('ant-tree-treenode-checkbox-checked'); + // eslint-disable-next-line testing-library/no-node-access + expect(layer2Span?.parentNode?.parentNode).toHaveClass('ant-tree-treenode-checkbox-checked'); + + expect(layer1.getVisible()).toBe(false); + expect(layer2.getVisible()).toBe(true); + }); + + it('updates the checked state if the layers visibility changes internally', async () => { + renderInMapContext(map, + + ); + + const layer1Span = screen.getByText('layer1'); + const layer2Span = screen.getByText('layer2'); + + // eslint-disable-next-line testing-library/no-node-access + expect(layer1Span?.parentNode?.parentNode).toHaveClass('ant-tree-treenode-checkbox-checked'); + // eslint-disable-next-line testing-library/no-node-access + expect(layer2Span?.parentNode?.parentNode).not.toHaveClass('ant-tree-treenode-checkbox-checked'); + + act(() => { + layer1.setVisible(false); + layer2.setVisible(true); }); - it('… also registers add/remove events for added groups ', () => { - const props = { - layerGroup, - map - }; - const layer = new OlLayerTile({ - source: new OlSourceTileWMS() - }); - const group = new OlLayerGroup({ - layers: [layer] - }); - const wrapper = TestUtil.mountComponent(LayerTree, props); - const rebuildSpy = jest.spyOn((wrapper.instance() as LayerTree), 'rebuildTreeNodes'); - const registerSpy = jest.spyOn((wrapper.instance() as LayerTree), 'registerAddRemoveListeners'); - - layerGroup.getLayers().push(group); - expect(rebuildSpy).toHaveBeenCalled(); - expect(registerSpy).toHaveBeenCalled(); - - rebuildSpy.mockRestore(); - registerSpy.mockRestore(); + // eslint-disable-next-line testing-library/no-node-access + expect(layer1Span?.parentNode?.parentNode).not.toHaveClass('ant-tree-treenode-checkbox-checked'); + // eslint-disable-next-line testing-library/no-node-access + expect(layer2Span?.parentNode?.parentNode).toHaveClass('ant-tree-treenode-checkbox-checked'); + }); + + it('sets the out-of-range class if the layer is not visible', () => { + renderInMapContext(map, + + ); + + const layer1Span = screen.getByText('layer1'); + + // eslint-disable-next-line testing-library/no-node-access + expect(layer1Span?.parentNode?.parentNode).toHaveClass('out-of-range'); + + act(() => { + map.getView().setZoom(10); }); - it('trigger unregisterEventsByLayer and rebuildTreeNodes for removed layers ', () => { - const props = { - layerGroup, - map - }; - const wrapper = TestUtil.mountComponent(LayerTree, props); - const rebuildSpy = jest.spyOn((wrapper.instance() as LayerTree), 'rebuildTreeNodes'); - const unregisterSpy = jest.spyOn((wrapper.instance() as LayerTree), 'unregisterEventsByLayer'); - - layerGroup.getLayers().remove(layer1); - expect(rebuildSpy).toHaveBeenCalled(); - expect(unregisterSpy).toHaveBeenCalled(); - - rebuildSpy.mockRestore(); - unregisterSpy.mockRestore(); + // eslint-disable-next-line testing-library/no-node-access + expect(layer1Span?.parentNode?.parentNode).not.toHaveClass('out-of-range'); + }); + + it('adds/removes layers to the tree if added/removed internally', () => { + renderInMapContext(map, + + ); + + let newLayerSpan = screen.queryByText('newLayer'); + + expect(newLayerSpan).not.toBeInTheDocument(); + + const newLayer = new OlLayerTile({ + source: new OlSourceTileWMS(), + properties: { + name: 'newLayer' + } }); - it('… unregister recursively for removed groups', () => { - const props = { - layerGroup, - map - }; - const subLayer1 = new OlLayerTile({ - source: new OlSourceTileWMS() - }); - const subLayer2 = new OlLayerTile({ - source: new OlSourceTileWMS() - }); - const nestedLayerGroup = new OlLayerGroup({ - properties: { - name: 'nestedLayerGroup' - }, - layers: [subLayer1, subLayer2] - }); - layerGroup.getLayers().push(nestedLayerGroup); - - const wrapper = TestUtil.mountComponent(LayerTree, props); - const rebuildSpy = jest.spyOn((wrapper.instance() as LayerTree), 'rebuildTreeNodes'); - const unregisterSpy = jest.spyOn((wrapper.instance() as LayerTree), 'unregisterEventsByLayer'); - - layerGroup.getLayers().remove(nestedLayerGroup); - expect(rebuildSpy).toHaveBeenCalledTimes(1); - expect(unregisterSpy).toHaveBeenCalledTimes(3); - - rebuildSpy.mockRestore(); - unregisterSpy.mockRestore(); + act(() => { + map.addLayer(newLayer); }); - describe('#unregisterEventsByLayer', () => { - - it('removes the listener and the eventKey from olListenerKeys', () => { - const props = { - layerGroup, - map - }; - const subLayer1 = new OlLayerTile({ - source: new OlSourceTileWMS() - }); - const subLayer2 = new OlLayerTile({ - source: new OlSourceTileWMS() - }); - const nestedLayerGroup = new OlLayerGroup({ - properties: { - name: 'nestedLayerGroup' - }, - layers: [subLayer1, subLayer2] - }); - layerGroup.getLayers().push(nestedLayerGroup); - - const wrapper = TestUtil.mountComponent(LayerTree, props); - const oldOlListenerKey = (wrapper.instance() as LayerTree).olListenerKeys; - // @ts-ignore - OlObservable.unByKey = jest.fn(); - - (wrapper.instance() as LayerTree).unregisterEventsByLayer(subLayer1); - - const newOlListenerKey = (wrapper.instance() as LayerTree).olListenerKeys; - - expect(OlObservable.unByKey).toHaveBeenCalled(); - expect(newOlListenerKey.length).toBe(oldOlListenerKey.length - 1); - }); - - it('… of children for groups', () => { - const props = { - layerGroup, - map - }; - const subLayer1 = new OlLayerTile({ - source: new OlSourceTileWMS() - }); - const subLayer2 = new OlLayerTile({ - source: new OlSourceTileWMS() - }); - const nestedLayerGroup = new OlLayerGroup({ - properties: { - name: 'nestedLayerGroup' - }, - layers: [subLayer1, subLayer2] - }); - layerGroup.getLayers().push(nestedLayerGroup); - - const wrapper = TestUtil.mountComponent(LayerTree, props); - const oldOlListenerKey = (wrapper.instance() as LayerTree).olListenerKeys; - // @ts-ignore - OlObservable.unByKey = jest.fn(); - - (wrapper.instance() as LayerTree).unregisterEventsByLayer(nestedLayerGroup); - - const newOlListenerKey = (wrapper.instance() as LayerTree).olListenerKeys; - - expect(OlObservable.unByKey).toHaveBeenCalledTimes(2); - expect(newOlListenerKey.length).toBe(oldOlListenerKey.length - 2); - }); + newLayerSpan = screen.getByText('newLayer'); + + expect(newLayerSpan).toBeInTheDocument(); + act(() => { + map.removeLayer(newLayer); }); + expect(newLayerSpan).not.toBeInTheDocument(); }); - describe('#setLayerVisibility', () => { - it('sets the visibility of a single layer', () => { - const props = { - layerGroup, - map - }; - const wrapper = TestUtil.mountComponent(LayerTree, props); - layer1.setVisible(true); - - (wrapper.instance() as LayerTree).setLayerVisibility(layer1, false); - expect(layer1.getVisible()).toBe(false); - (wrapper.instance() as LayerTree).setLayerVisibility(layer1, true); - expect(layer1.getVisible()).toBe(true); - }); + it('updates the layer name if changed internally', () => { + renderInMapContext(map, + + ); - it('sets the visibility for the children when called with a layerGroup', () => { - const props = { - layerGroup, - map - }; - const wrapper = TestUtil.mountComponent(LayerTree, props); - layer1.setVisible(true); - layer2.setVisible(true); + let layer1Span = screen.queryByText('layer1'); - (wrapper.instance() as LayerTree).setLayerVisibility(layerGroup, false); - expect(layerGroup.getVisible()).toBe(false); - expect(layer1.getVisible()).toBe(false); - expect(layer2.getVisible()).toBe(false); + expect(layer1Span).toBeInTheDocument(); - (wrapper.instance() as LayerTree).setLayerVisibility(layerGroup, true); - expect(layerGroup.getVisible()).toBe(true); - expect(layer1.getVisible()).toBe(true); - expect(layer2.getVisible()).toBe(true); + act(() => { + layer1.set('name', 'newLayerName'); }); - it('sets the parent layerGroups visible when layer has been made visible', () => { - const props = { - layerGroup, - map - }; - const wrapper = TestUtil.mountComponent(LayerTree, props); - layerGroup.setVisible(false); - layerSubGroup.setVisible(false); - layer1.setVisible(false); - layer2.setVisible(false); - layer3.setVisible(false); - - - (wrapper.instance() as LayerTree).setLayerVisibility(layer2, true); - expect(layerGroup.getVisible()).toBe(true); - expect(layer1.getVisible()).toBe(false); - expect(layer2.getVisible()).toBe(true); - expect(layer3.getVisible()).toBe(false); - expect(layerSubGroup.getVisible()).toBe(false); - - (wrapper.instance() as LayerTree).setLayerVisibility(layer1, true); - expect(layerGroup.getVisible()).toBe(true); - expect(layer1.getVisible()).toBe(true); - expect(layer2.getVisible()).toBe(true); - expect(layer3.getVisible()).toBe(false); - expect(layerSubGroup.getVisible()).toBe(false); - - layerGroup.setVisible(false); - layerSubGroup.setVisible(false); - layer1.setVisible(false); - layer2.setVisible(false); - layer3.setVisible(false); - - (wrapper.instance() as LayerTree).setLayerVisibility(layer3, true); - expect(layer1.getVisible()).toBe(false); - expect(layer2.getVisible()).toBe(false); - expect(layer3.getVisible()).toBe(true); - expect(layerSubGroup.getVisible()).toBe(true); - expect(layerGroup.getVisible()).toBe(true); - }); + layer1Span = screen.queryByText('layer1'); + expect(layer1Span).not.toBeInTheDocument(); + + layer1Span = screen.queryByText('newLayerName'); + + expect(layer1Span).toBeInTheDocument(); + }); + + it('sets the visibility for the children when called with a layerGroup', async () => { + renderInMapContext(map, + + ); + + const layerSubGroupSpan = screen.getByText('layerSubGroup'); + + expect(layer3.getVisible()).toBe(false); + + await userEvent.click(layerSubGroupSpan); + + expect(layer3.getVisible()).toBe(true); }); - // TODO rc-tree drop event seems to be broken in PhantomJS / cant be simulated - describe('#onDrop', () => { - - // let props = {}; - // - // beforeEach(() => { - // props = { - // layerGroup, - // map - // }; - // const layer3 = new OlLayerTile({ - // name: 'layer3', - // source: new OlSourceTileWMS() - // }); - // const subLayer = new OlLayerTile({ - // name: 'subLayer', - // source: new OlSourceTileWMS() - // }); - // const nestedLayerGroup = new OlLayerGroup({ - // name: 'nestedLayerGroup', - // layers: [subLayer] - // }); - // props.layerGroup.getLayers().push(layer3); - // props.layerGroup.getLayers().push(nestedLayerGroup); - // }); - // - // it('can handle drop on leaf', () => { - // const wrapper = TestUtil.mountComponent(LayerTree, props); - // const firstNode = wrapper.childAt(0); - // const thirdNode = wrapper.childAt(2); - // const dragTarget = firstNode.find('.ant-tree-node-content-wrapper'); - // const dropTarget = thirdNode.find('.react-geo-layertree-node'); - // - // console.log(props.layerGroup.getLayers().getLength()); - // console.log(props.layerGroup.getLayers().item(0).get('name')); - // console.log(props.layerGroup.getLayers().item(1).get('name')); - // console.log(props.layerGroup.getLayers().item(2).get('name')); - // console.log(props.layerGroup.getLayers().item(3).get('name')); - // - // debugger - // - // console.log('--------'); - // dragTarget.simulate('dragStart'); - // dropTarget.simulate('dragOver'); - // dropTarget.simulate('drop'); - // - // console.log(props.layerGroup.getLayers().getLength()); - // console.log(props.layerGroup.getLayers().item(0).get('name')); - // console.log(props.layerGroup.getLayers().item(1).get('name')); - // console.log(props.layerGroup.getLayers().item(2).get('name')); - // console.log(props.layerGroup.getLayers().item(3).get('name')); - // - // - // }); - - // it('can handle drop before leaf', () => { - // const wrapper = TestUtil.mountComponent(LayerTree, props); - // const treeNodes = wrapper.children('TreeNode'); - // const firstNode = treeNodes.get(0); - // const thirdNode = treeNodes.get(2); - // }); - // - // it('can handle drop after leaf', () => { - // const wrapper = TestUtil.mountComponent(LayerTree, props); - // const treeNodes = wrapper.children('TreeNode'); - // const firstNode = treeNodes.get(0); - // const thirdNode = treeNodes.get(2); - // }); - // - // it('can handle drop on folder', () => { - // const wrapper = TestUtil.mountComponent(LayerTree, props); - // const treeNodes = wrapper.children('TreeNode'); - // const firstNode = treeNodes.get(0); - // const folderNode = treeNodes.get(3); - // }); + it('sets the parent layerGroup visible when layer has been made visible', async () => { + renderInMapContext(map, + + ); + + // Expand all nodes. + const carets = screen.getAllByLabelText('caret-down'); + for (const caret of carets) { + await userEvent.click(caret); + } + + const layerSubGroupSpan = screen.getByText('layerSubGroup'); + + // eslint-disable-next-line testing-library/no-node-access + let layerSubGroupCheckbox = layerSubGroupSpan?.parentNode?.parentNode?.querySelector('.ant-tree-checkbox-checked'); + + expect(layerSubGroupCheckbox).toBe(null); + + const layer3Span = screen.getByText('layer3'); + + await userEvent.click(layer3Span); + + // eslint-disable-next-line testing-library/no-node-access + layerSubGroupCheckbox = layerSubGroupSpan?.parentNode?.parentNode?.querySelector('.ant-tree-checkbox-checked'); + + expect(layerSubGroupCheckbox).toBeInstanceOf(HTMLSpanElement); + }); + + it('renders the layers in correct order', () => { + renderInMapContext(map, + + ); + + // Last drawn map layer (layerSubGroup) should be on top of the tree. + const layerSubGroupSpan = screen.getByText('layerSubGroup'); + const layer2Span = screen.getByText('layer2'); + const layer1Span = screen.getByText('layer1'); + + // https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition + expect(layerSubGroupSpan.compareDocumentPosition(layer2Span)).toEqual(4); + expect(layer2Span.compareDocumentPosition(layer1Span)).toEqual(4); + }); + + it('can handle drop on a node', () => { + renderInMapContext(map, + + ); + + const layers = map.getLayers().getArray(); + + expect(layers[0]).toBe(layer1); + expect(layers[1]).toBe(layer2); + expect(layers[2]).toBe(layerSubGroup); + + const layerSubGroupSpan = screen.getByText('layerSubGroup'); + const layer1Span = screen.getByText('layer1'); + + // Move layer1 on layerSubGroup (relative position = 0). + fireEvent.dragStart(layer1Span); + fireEvent.dragEnter(layerSubGroupSpan); + fireEvent.dragOver(layerSubGroupSpan); + fireEvent.drop(layerSubGroupSpan); + + expect(layers[0]).toBe(layer2); + expect(layers[1]).toBe(layerSubGroup); + expect((layers[1] as OlLayerGroup).getLayers().getArray()).toContain(layer1); + }); + + it('ignores drop on leaf', () => { + renderInMapContext(map, + + ); + + const layers = map.getLayers().getArray(); + + expect(layers[0]).toBe(layer1); + expect(layers[1]).toBe(layer2); + expect(layers[2]).toBe(layerSubGroup); + + const layer2Span = screen.getByText('layer2'); + const layer1Span = screen.getByText('layer1'); + + // Move layer1 on layer2 (relative position = 0). + fireEvent.dragStart(layer1Span); + fireEvent.dragEnter(layer2Span); + fireEvent.dragOver(layer2Span); + fireEvent.drop(layer2Span); + + expect(layers[0]).toBe(layer1); + expect(layers[1]).toBe(layer2); + expect(layers[2]).toBe(layerSubGroup); + }); + + it('can handle drop after leaf', () => { + renderInMapContext(map, + + ); + + const layers = map.getLayers().getArray(); + + expect(layers[0]).toBe(layer1); + expect(layers[1]).toBe(layer2); + expect(layers[2]).toBe(layerSubGroup); + + const layerSubGroupSpan = screen.getByText('layerSubGroup'); + const layer2Span = screen.getByText('layer2'); + + // Move layerSubGroup on bottom of layer2 (relative position = 1). + fireEvent.dragStart(layerSubGroupSpan); + fireEvent.dragEnter(layer2Span); + fireEvent.dragOver(layer2Span); + fireEvent.drop(layer2Span); + + expect(layers[0]).toBe(layer1); + expect(layers[1]).toBe(layerSubGroup); + expect(layers[2]).toBe(layer2); }); + // TODO This seems not to working with the jest runner. + // it('can handle drop before leaf', () => { + // renderInMapContext(map, + // + // ); + + // const layers = map.getLayers().getArray(); + + // expect(layers[0]).toBe(layer1); + // expect(layers[1]).toBe(layer2); + // expect(layers[2]).toBe(layerSubGroup); + + // const layerSubGroupSpan = screen.getByText('layerSubGroup'); + // const layer2Span = screen.getByText('layer2'); + // const layer1Span = screen.getByText('layer1'); + + // // Move layer1 on top of layer2 (relative position = 1). + // fireEvent.dragStart(layer1Span); + // fireEvent.dragEnter(layer2Span); + // fireEvent.dragOver(layer2Span); + // // fireEvent.dragLeave(layer2Span); + // // fireEvent.dragExit(layer2Span); + // fireEvent.dragEnter(layerSubGroupSpan); + // // fireEvent.dragOver(layer2Span); + // fireEvent.drop(layerSubGroupSpan); + + // // expect(layers[0]).toBe(layer2); + // // expect(layers[1]).toBe(layer1); + // // expect(layers[2]).toBe(layerSubGroup); + // }); }); diff --git a/src/LayerTree/LayerTree.tsx b/src/LayerTree/LayerTree.tsx index 8be72572bb..3b58177a0c 100644 --- a/src/LayerTree/LayerTree.tsx +++ b/src/LayerTree/LayerTree.tsx @@ -1,31 +1,47 @@ +import './LayerTree.less'; + import Logger from '@terrestris/base-util/dist/Logger'; import MapUtil from '@terrestris/ol-util/dist/MapUtil/MapUtil'; -import { Tree } from 'antd'; +import useMap from '@terrestris/react-util/dist/Hooks/useMap/useMap'; +import { + Tree, + TreeDataNode +} from 'antd'; import { - AntTreeNodeCheckedEvent, + BasicDataNode, DataNode, TreeProps} from 'antd/lib/tree'; -import { AntTreeNodeDropEvent, AntTreeNodeSelectedEvent } from 'antd/lib/tree/Tree'; import _isEqual from 'lodash/isEqual'; import _isFunction from 'lodash/isFunction'; import _isNumber from 'lodash/isNumber'; import { getUid } from 'ol'; -import OlCollection from 'ol/Collection'; -import { EventsKey as OlEventsKey } from 'ol/events'; +import { + EventsKey as OlEventsKey, +} from 'ol/events'; import OlLayerBase from 'ol/layer/Base'; import OlLayerGroup from 'ol/layer/Group'; -import OlMap from 'ol/Map'; -import OlMapEvent from 'ol/MapEvent'; -import { unByKey } from 'ol/Observable'; +import { + unByKey +} from 'ol/Observable'; +import { + NodeDragEventParams +} from 'rc-tree/lib/contextTypes'; import { EventDataNode } from 'rc-tree/lib/interface'; -import * as React from 'react'; -import { ReactElement, ReactNode } from 'react'; +import { + AllowDropOptions, + CheckInfo +} from 'rc-tree/lib/Tree'; +import React, { + Key, + useCallback, + useEffect, + useRef, + useState +} from 'react'; import { CSS_PREFIX } from '../constants'; -import LayerTreeNode, { LayerTreeNodeProps } from './LayerTreeNode/LayerTreeNode'; - interface OwnProps { /** @@ -35,618 +51,342 @@ interface OwnProps { * * Compare MDN Docs for Array.prototype.filter: https://mdn.io/array/filter */ - filterFunction: (value: any, index: number, array: any[]) => boolean; - /** - * An optional CSS class which should be added. - */ - className?: string; + filterFunction?: (value: OlLayerBase, index: number, array: OlLayerBase[]) => boolean; /** * A LayerGroup the Tree should handle. */ layerGroup?: OlLayerGroup; - /** - * The OpenLayers map the tree interacts with. - */ - map: OlMap; - /** * A function that can be used to pass a custom node title. It can return * any renderable element (String, Number, Element etc.) and receives * the layer instance of the current tree node. */ nodeTitleRenderer?: (layer: OlLayerBase) => React.ReactNode; - - toggleOnClick?: boolean; } -interface LayerTreeState { - layerGroup: OlLayerGroup | null; - layerGroupRevision: number; - treeNodes: ReactElement[]; - checkedKeys: React.ReactText[]; - mapResolution: number; -} +const defaultClassName = `${CSS_PREFIX}layertree`; -export type LayerTreeProps = OwnProps & TreeProps; +export type LayerTreeProps = OwnProps & TreeProps; -/** - * The LayerTree. - * - * Note. This component expects that all layerGroups are permanently visible. - * - * @class LayerTree - * @extends React.Component - */ -class LayerTree extends React.Component { +const LayerTree: React.FC = ({ + className, + layerGroup, + nodeTitleRenderer, + filterFunction, + checkable = true, + draggable = true, + ...passThroughProps +}) => { + const [checkedKeys, setCheckedKeys] = useState([]); + const [treeData, setTreeData] = useState([]); - /** - * The default properties. - */ - static defaultProps = { - draggable: true, - checkable: true, - filterFunction: () => true, - toggleOnClick: false - }; + const olListenerKeys = useRef([]); - /** - * The className added to this component. - * @private - */ - className = `${CSS_PREFIX}layertree`; + const map = useMap(); - /** - * An array of ol.EventsKey as returned by on() or once(). - * @private - */ - olListenerKeys: OlEventsKey[] = []; + const getVisibleLayerKeys = useCallback(() => { + if (!map) { + return []; + } - /** - * Create the LayerTree. - * - * @constructs LayerTree - */ - constructor(props: LayerTreeProps) { - super(props); - - this.state = { - layerGroup: null, - layerGroupRevision: 0, - treeNodes: [], - checkedKeys: [], - mapResolution: -1 - }; - } + const lGroup = layerGroup ? MapUtil.getAllLayers(layerGroup) : MapUtil.getAllLayers(map); - /** - * Invoked after the component is instantiated as well as when it - * receives new props. It should return an object to update state, or null - * to indicate that the new props do not require any state updates. - * - * @param nextProps The next properties. - * @param prevState The previous state. - */ - static getDerivedStateFromProps(nextProps: LayerTreeProps, prevState: LayerTreeState) { - if (prevState.layerGroup && nextProps.layerGroup) { - if (!_isEqual(getUid(prevState.layerGroup), getUid(nextProps.layerGroup)) || - !_isEqual(prevState.layerGroupRevision, nextProps.layerGroup.getRevision())) { - return { - layerGroup: nextProps.layerGroup, - layerGroupRevision: nextProps.layerGroup.getRevision() - }; - } + let visibleLayers = lGroup + .filter(layer => !(layer instanceof OlLayerGroup) && layer.getVisible()); + + if (filterFunction) { + visibleLayers = visibleLayers.filter(filterFunction); } - return null; - } - /** - * Determines what to do on the initial mount. - */ - componentDidMount() { - const layerGroup = this.props.layerGroup ? - this.props.layerGroup : - this.props.map.getLayerGroup(); - - const revision = this.props.layerGroup ? this.props.layerGroup.getRevision() : 0; - - this.setState({ - layerGroup, - layerGroupRevision: revision - }, () => { - if (this.state.layerGroup) { - this.registerAddRemoveListeners(this.state.layerGroup); - } - this.registerResolutionChangeHandler(); - this.rebuildTreeNodes(); - }); - } + const visibleKeys = visibleLayers.map(getUid); - /** - * Invoked immediately after updating occurs. This method is not called for - * the initial render. - * - * @param prevProps The previous props. - * @param prevState The previous state. - */ - componentDidUpdate(prevProps: LayerTreeProps, prevState: LayerTreeState) { - const { - layerGroup, - nodeTitleRenderer - } = this.props; - - if (layerGroup && prevState.layerGroup) { - if (!_isEqual(getUid(prevState.layerGroup), getUid(layerGroup))) { - unByKey(this.olListenerKeys); - this.olListenerKeys = []; - - this.registerAddRemoveListeners(layerGroup); - this.rebuildTreeNodes(); - } - } + return visibleKeys; + }, [filterFunction, layerGroup, map]); - if (nodeTitleRenderer !== prevProps.nodeTitleRenderer) { - this.rebuildTreeNodes(); + const getTreeNodeTitle = useCallback((layer: OlLayerBase) => { + if (_isFunction(nodeTitleRenderer)) { + return nodeTitleRenderer(layer); + } else { + return layer.get('name'); } - } + }, [nodeTitleRenderer]); - /** - * Determines what to do when the component is unmounted. - */ - componentWillUnmount() { - unByKey(this.olListenerKeys); - } - - /** - * Creates TreeNodes from a given layergroup and sets the treeNodes in the state. - * - * @param groupLayer A grouplayer. - */ - treeNodesFromLayerGroup(groupLayer: OlLayerGroup) { - const layerArray = groupLayer.getLayers().getArray() - .filter(this.props.filterFunction); - const treeNodes = layerArray.map((layer) => { - return this.treeNodeFromLayer(layer); + const hasListener = useCallback((key: OlEventsKey) => { + return olListenerKeys.current.some(listenerKey => { + return listenerKey.target === key.target + && listenerKey.type === key.type + && listenerKey.listener === key.listener; }); - treeNodes.reverse(); - this.setState({ treeNodes }); - } + }, []); - /** - * Registers the add/remove listeners recursively for all ol.layer.Group. - * - * @param groupLayer A ol.layer.Group - */ - registerAddRemoveListeners(groupLayer: OlLayerGroup) { - const collection = groupLayer.getLayers(); - const addEvtKey = collection.on('add', this.onCollectionAdd); - const removeEvtKey = collection.on('remove', this.onCollectionRemove); - const changeEvtKey = groupLayer.on('change:layers', this.onChangeLayers); + const treeNodeFromLayer = useCallback((layer: OlLayerBase) => { + let childNodes: TreeDataNode[] = []; - this.olListenerKeys.push(addEvtKey, removeEvtKey, changeEvtKey); + if (filterFunction && [layer].filter(filterFunction).length === 0) { + return; + } - collection.forEach((layer) => { - if ((layer as OlLayerGroup).getLayers) { - this.registerAddRemoveListeners((layer as OlLayerGroup)); + if (layer instanceof OlLayerGroup) { + let childLayers = layer.getLayers().getArray(); + if (filterFunction) { + childLayers = childLayers.filter(filterFunction); } - }); - } - - /** - * Registers an eventhandler on the `ol.View`, which will rebuild the tree - * nodes whenever the view's resolution changes. - */ - registerResolutionChangeHandler() { - const { map } = this.props; - const evtKey = map.on('moveend', this.rebuildTreeNodes.bind(this)); - this.olListenerKeys.push(evtKey); // TODO when and how to we unbind? - } + childNodes = childLayers + .map(childLayer => treeNodeFromLayer(childLayer)) + .filter(childLayer => childLayer !== undefined) + .toReversed() as TreeDataNode[]; + } - /** - * Listens to the collections add event of a collection. - * Registers add/remove listeners if element is a collection and rebuilds the - * treeNodes. - * - * @param evt The add event. - */ - onCollectionAdd = (evt: any) => { - if ((evt.element as OlLayerGroup).getLayers) { - this.registerAddRemoveListeners(evt.element); + return { + key: getUid(layer), + title: getTreeNodeTitle(layer), + className: MapUtil.layerInResolutionRange(layer, map) ? '' : 'out-of-range', + // Required to identify a group layer/node. + children: layer instanceof OlLayerGroup ? childNodes : undefined + } as TreeDataNode; + }, [map, getTreeNodeTitle, filterFunction]); + + const treeNodesFromLayerGroup = useCallback(() => { + if (!map) { + return []; } - this.rebuildTreeNodes(); - }; - /** - * Listens to the collections remove event of a collection. - * Unregisters the events of deleted layers and rebuilds the treeNodes. - * - * @param evt The remove event. - */ - onCollectionRemove = (evt: any) => { - this.unregisterEventsByLayer(evt.element); - if ((evt.element as OlLayerGroup).getLayers) { - evt.element.getLayers().forEach((layer: OlLayerBase) => { - this.unregisterEventsByLayer(layer); + const lGroup = layerGroup ? layerGroup : map.getLayerGroup(); + + return lGroup.getLayers().getArray() + .map(l => treeNodeFromLayer(l)) + .filter(n => n !== undefined) + .toReversed() as TreeDataNode[]; + }, [layerGroup, map, treeNodeFromLayer]); + + const onChangeResolution = useCallback(() => { + setTreeData(treeNodesFromLayerGroup()); + }, [treeNodesFromLayerGroup]); + + const setLayerVisibility = useCallback((layer: OlLayerBase, visible: boolean) => { + if (layer instanceof OlLayerGroup) { + layer.getLayers().forEach(subLayer => { + setLayerVisibility(subLayer, visible); }); + } else { + layer.setVisible(visible); } - this.rebuildTreeNodes(); - }; + }, []); - /** - * Listens to the LayerGroups change:layers event. - * Unregisters the old and reregisters new listeners. - * - * @param evt The change event. - */ - onChangeLayers = (evt: any) => { - this.unregisterEventsByLayer(evt.oldValue); - if (evt.oldValue instanceof OlCollection) { - evt.oldValue.forEach((layer: OlLayerBase) => this.unregisterEventsByLayer(layer)); - } - if (evt.target.getLayers) { - this.registerAddRemoveListeners(evt.target); - } - this.rebuildTreeNodes(); - }; + const updateTreeNodes = useCallback(() => { + setTreeData(treeNodesFromLayerGroup()); + }, [treeNodesFromLayerGroup]); - /** - * Unregisters the Events of a given layer. - * - * @param layer An ol.layer.Base. - */ - unregisterEventsByLayer = (layer: OlLayerBase) => { - this.olListenerKeys = this.olListenerKeys.filter((key) => { - if ((layer as OlLayerGroup).getLayers) { - const layers = (layer as OlLayerGroup).getLayers(); - if (key.target === layers) { - if ((key.type === 'add' && key.listener === this.onCollectionAdd) || - (key.type === 'remove' && key.listener === this.onCollectionRemove) || - (key.type === 'change:layers' && key.listener === this.onChangeLayers)) { - - unByKey(key); - return false; - } - } - } else if (key.target === layer) { - if (key.type === 'change:visible' && key.listener === this.onLayerChangeVisible) { - unByKey(key); - return false; - } - } - return true; - }); - }; + const updateCheckedKeys = useCallback(() => { + setCheckedKeys(getVisibleLayerKeys()); + }, [getVisibleLayerKeys]); - /** - * Rebuilds the treeNodes and its checked states. - * @param evt The OpenLayers MapEvent (passed by moveend) - * - */ - rebuildTreeNodes = (evt?: OlMapEvent) => { - const { mapResolution } = this.state; + const updateTree = useCallback(() => { + updateTreeNodes(); + updateCheckedKeys(); + }, [updateTreeNodes, updateCheckedKeys]); - let newMapResolution: number = -1; + useEffect(() => { + updateTree(); + }, [updateTree]); - if (evt?.target instanceof OlMap) { - newMapResolution = evt.target.getView().getResolution() ?? -1; - if (mapResolution === newMapResolution) { - // If map resolution didn't change => no redraw of tree nodes needed. - return; - } + const registerLayerListeners = useCallback((layer: OlLayerBase) => { + if (filterFunction && [layer].filter(filterFunction).length === 0) { + return; } - if (this.state.layerGroup) { - this.treeNodesFromLayerGroup(this.state.layerGroup); + if (!(hasListener({ target: layer, type: 'propertychange', listener: updateTree }))) { + const evtKey = layer.on('propertychange', updateTree); + olListenerKeys.current.push(evtKey); } - const checkedKeys = this.getVisibleOlUids(); - this.setState({ - checkedKeys, - mapResolution: newMapResolution - }); - }; + if (layer instanceof OlLayerGroup) { + const layerCollection = layer.getLayers(); - /** - * Returns the title to render in the LayerTreeNode. If a nodeTitleRenderer - * has been passed as prop, it will be called and the (custom) return value - * will be rendered. Note: This can be any renderable element collection! If - * no function is given (the default) the layer name will be passed. - * - * @param layer The layer attached to the tree node. - * @return The title composition to render. - */ - getTreeNodeTitle(layer: OlLayerBase) { - if (_isFunction(this.props.nodeTitleRenderer)) { - return this.props.nodeTitleRenderer.call(this, layer); - } else { - return layer.get('name'); - } - } + if (!(hasListener({ target: layerCollection, type: 'add', listener: updateTree }))) { + const addEvtKey = layerCollection.on('add', updateTree); + olListenerKeys.current.push(addEvtKey); + } + if (!(hasListener({ target: layerCollection, type: 'remove', listener: updateTree }))) { + const removeEvtKey = layerCollection.on('remove', updateTree); + olListenerKeys.current.push(removeEvtKey); + } + if (!(hasListener({ target: layer, type: 'change:layers', listener: updateTree }))) { + const changeEvtKey = layer.on('change:layers', updateTree); + olListenerKeys.current.push(changeEvtKey); + } - /** - * Creates a treeNode from a given layer. - * - * @param layer The given layer. - * @return The corresponding LayerTreeNode Element. - */ - treeNodeFromLayer(layer: OlLayerBase): ReactElement { - let childNodes: ReactNode = null; - - if ((layer as OlLayerGroup).getLayers) { - const childLayers = (layer as OlLayerGroup).getLayers().getArray() - .filter(this.props.filterFunction); - childNodes = childLayers.map((childLayer: OlLayerBase) => { - return this.treeNodeFromLayer(childLayer); - }).reverse(); + for (const lay of layerCollection.getArray()) { + registerLayerListeners(lay); + } } else { - if (!this.hasListener({ target: layer, type: 'change:visible', listener: this.onLayerChangeVisible })) { - const eventKey = layer.on('change:visible', this.onLayerChangeVisible); - this.olListenerKeys.push(eventKey); + if (!(hasListener({ target: layer, type: 'change:visible', listener: updateCheckedKeys }))) { + const evtKey = layer.on('change:visible', updateCheckedKeys); + olListenerKeys.current.push(evtKey); } } + }, [filterFunction, hasListener, updateTree, updateCheckedKeys]); - return ( - - {childNodes} - - ); - } + const registerAllLayerListeners = useCallback(() => { + if (!map) { + return; + } - /** - * Determines if the target has already registered the given listener for the - * given eventtype. - */ - hasListener = (key: OlEventsKey) => { - return this.olListenerKeys.some((listenerKey) => { - return listenerKey.target === key.target - && listenerKey.type === key.type - && listenerKey.listener === key.listener; - }); - }; + const lGroup = layerGroup ? layerGroup : map.getLayerGroup(); - /** - * Reacts to the layer change:visible event and calls setCheckedState. - */ - onLayerChangeVisible = () => { - const checkedKeys = this.getVisibleOlUids(); - this.setState({ - checkedKeys - }, () => { - this.rebuildTreeNodes(); - }); - }; + registerLayerListeners(lGroup); - /** - * Get the flat array of ol_uids from visible non groupLayers. - * - * @return The visible ol_uids. - */ - getVisibleOlUids = (): string[] => { - if (!this.state.layerGroup) { - return []; - } + }, [layerGroup, map, registerLayerListeners]); - return MapUtil.getAllLayers(this.state.layerGroup, - (layer: OlLayerBase) => { - return !((layer as OlLayerGroup).getLayers) && layer.getVisible(); - }) - .filter(this.props.filterFunction) - .map(getUid); - }; + const unregisterAllLayerListeners = useCallback(() => { + unByKey(olListenerKeys.current); + }, []); - /** - * Sets the visibility of a layer due to its checked state. - * - * @param checkedKeys Contains all checkedKeys. - * @param e The ant-tree event object for this event. See ant docs. - */ - onCheck(checkedKeys: string[], e: AntTreeNodeCheckedEvent) { - const { checked = false } = e; - const eventKey = e.node.props.eventKey; - if (eventKey) { - const layer = MapUtil.getLayerByOlUid(this.props.map, eventKey); - if (!layer) { - Logger.error('Layer is not defined'); - return; - } - this.setLayerVisibility(layer, checked); + useEffect(() => { + registerAllLayerListeners(); + + return () => { + unregisterAllLayerListeners(); + }; + }, [registerAllLayerListeners, unregisterAllLayerListeners]); + + // Reregister all layer listeners if the treeData changes, this is e.g. required if a layer becomes + // a child of a layer group that isn't part of the treeData yet. + useEffect(() => { + unregisterAllLayerListeners(); + registerAllLayerListeners(); + }, [treeData, registerAllLayerListeners, unregisterAllLayerListeners]); + + useEffect(() => { + if (!map) { + return; } - } - /** - * Toggles the visibility of a layer when clicking the name. - * - * @param checkedKeys Contains all checkedKeys. - * @param e The ant-tree event object for this event. See ant docs. - */ - onSelect = (checkedKeys: string[], e: AntTreeNodeSelectedEvent) => { - // @ts-ignore - const checked = !e.node.checked; - // @ts-ignore - e.node.checked = checked; - const eventKey = e.node.props.eventKey; - if (eventKey) { - const layer = MapUtil.getLayerByOlUid(this.props.map, eventKey); - if (!layer) { - Logger.error('layer is not defined'); - return; - } - this.setLayerVisibility(layer, checked); + map.getView().on('change:resolution', onChangeResolution); + + return () => { + map.getView().un('change:resolution', onChangeResolution); + }; + }, [map, onChangeResolution]); + + const onCheck = (keys: { + checked: Key[]; + halfChecked: Key[]; + } | Key[], info: CheckInfo) => { + if (!map) { + return; } - }; - /** - * Sets the layer visibility. Calls itself recursively for groupLayers. - * - * @param layer The layer. - * @param visibility The visibility. - */ - setLayerVisibility(layer: OlLayerBase, visibility: boolean) { - if ((layer as OlLayerGroup).getLayers) { - layer.setVisible(visibility); - (layer as OlLayerGroup).getLayers().forEach((subLayer) => { - this.setLayerVisibility(subLayer, visibility); - }); - } else { - layer.setVisible(visibility); - // if layer has a parent folder, make it visible too - if (visibility) { - const group = this.props.layerGroup ? this.props.layerGroup : - this.props.map.getLayerGroup(); - this.setParentFoldersVisible(group, getUid(layer), group); - } + const key = info.node.key as string; + const checked = info.checked; + + if (!key) { + return; } - } - /** - * Find the parent OlLayerGroup for the given layers ol_uid and make it - * visible. Traverse the tree to also set the parenting layer groups visible - * - * @param currentGroup The current group to search in - * @param olUid The ol_uid of the layer or folder that has been set visible - * @param mainGroup The main group to search in. Needed when searching for - * parents as we always have to start search from top - */ - setParentFoldersVisible(currentGroup: OlLayerGroup, olUid: string, mainGroup: OlLayerGroup) { - const items = currentGroup.getLayers().getArray(); - const groups = items.filter(l => l instanceof OlLayerGroup) as OlLayerGroup[]; - const match = items.find(i => getUid(i) === olUid); - if (match) { - currentGroup.setVisible(true); - this.setParentFoldersVisible(mainGroup, getUid(currentGroup), mainGroup); + const layer = MapUtil.getLayerByOlUid(map, key); + + if (!layer) { + Logger.error('Layer is not defined'); return; } - groups.forEach(g => { - this.setParentFoldersVisible(g, olUid, mainGroup); - }); - } - /** - * The callback method for the drop event. Layers will get reordered in the map - * and the tree. - * - * @param e The ant-tree event object for this event. See ant docs. - */ - onDrop(e: AntTreeNodeDropEvent) { - if (!e.dragNode.props.eventKey || !e.node.props.eventKey) { + setLayerVisibility(layer, checked); + }; + + const onDrop = (info: NodeDragEventParams & { + dragNode: EventDataNode; + dragNodesKeys: Key[]; + dropPosition: number; + dropToGap: boolean; + }) => { + const dropKey = info.node.key as string; + const dragKey = info.dragNode.key as string; + const dropPos = info.node.pos.split('-'); + const dropPosition = info.dropPosition; + // The drop position relative to the drop node, inside 0, top -1, bottom 1. + const dropPositionRelative = dropPosition - parseInt(dropPos[dropPos.length - 1], 10); + + // Reorder layers + if (!map) { return; } - const dragLayer = MapUtil.getLayerByOlUid(this.props.map, e.dragNode.props.eventKey); + + const dragLayer = MapUtil.getLayerByOlUid(map, dragKey); if (!dragLayer) { Logger.error('dragLayer is not defined'); return; } - const dragInfo = MapUtil.getLayerPositionInfo(dragLayer, this.props.map); - if (!dragInfo || !dragInfo?.groupLayer) { - return; - } - const dragCollection = dragInfo.groupLayer.getLayers(); - const dropLayer = MapUtil.getLayerByOlUid(this.props.map, e.node.props.eventKey); + + const dropLayer = MapUtil.getLayerByOlUid(map, dropKey); if (!dropLayer) { Logger.error('dropLayer is not defined'); return; } - const dropPos = e.node.props.pos.split('-'); - const location = e.dropPosition - Number(dropPos[dropPos.length - 1]); - dragCollection.remove(dragLayer); + const dragInfo = MapUtil.getLayerPositionInfo(dragLayer, map); + if (!dragInfo || !dragInfo?.groupLayer) { + return; + } - const dropInfo = MapUtil.getLayerPositionInfo(dropLayer, this.props.map); + const dropInfo = MapUtil.getLayerPositionInfo(dropLayer, map); if (!dropInfo || !dropInfo?.groupLayer) { return; } - const dropPosition = dropInfo.position; + + const dragCollection = dragInfo.groupLayer.getLayers(); const dropCollection = dropInfo.groupLayer.getLayers(); - if (!_isNumber(dropPosition)) { - return; - } + dragCollection.remove(dragLayer); - // drop before node - if (location === -1) { + const dropLayerIndex = dropCollection.getArray().findIndex(l => l === dropLayer); + + // Drop on the top of the drop node/layer. + if (dropPositionRelative === -1) { if (dropPosition === dropCollection.getLength() - 1) { dropCollection.push(dragLayer); } else { - dropCollection.insertAt(dropPosition + 1, dragLayer); + dropCollection.insertAt(dropLayerIndex + 1, dragLayer); } - // drop on node - } else if (location === 0) { - if ((dropLayer as OlLayerGroup).getLayers) { - (dropLayer as OlLayerGroup).getLayers().push(dragLayer); - } else { - dropCollection.insertAt(dropPosition + 1, dragLayer); + // Drop on node (= to a layer group). + } else if (dropPositionRelative === 0) { + if (dropLayer instanceof OlLayerGroup) { + dropLayer.getLayers().push(dragLayer); } - // drop after node - } else if (location === 1) { - dropCollection.insertAt(dropPosition, dragLayer); + // Drop on the bottom of the drop node/layer. + } else if (dropPositionRelative === 1) { + dropCollection.insertAt(dropLayerIndex, dragLayer); } + }; - this.rebuildTreeNodes(); - } - - /** - * Call rebuildTreeNodes onExpand to avoid sync issues. - * - */ - onExpand = (expandedKeys: string[], info: { - node: EventDataNode; - expanded: boolean; - nativeEvent: MouseEvent; - }) => { - const { - onExpand - } = this.props; - - this.rebuildTreeNodes(); + const allowDrop = (options: AllowDropOptions) => { + const dropNode = options.dropNode; + const dropPositionRelative = options.dropPosition; - if (onExpand) { - onExpand(expandedKeys, info); - } + // Don't allow dropping on a layer node. + return !(dropPositionRelative === 0 && !dropNode.children); }; - /** - * The render function. - */ - render() { - const { - className, - layerGroup, - map, - nodeTitleRenderer, - toggleOnClick, - ...passThroughProps - } = this.props; - - let ddListeners: any; - if (passThroughProps.draggable) { - ddListeners = { - onDrop: this.onDrop.bind(this) - }; - } - - const finalClassName = className - ? `${className} ${this.className}` - : this.className; - - return ( - - {this.state.treeNodes} - - ); - } -} + const finalClassName = className + ? `${className} ${defaultClassName}` + : defaultClassName; + + return ( + + ); +}; export default LayerTree; diff --git a/src/LayerTree/LayerTreeNode/LayerTreeNode.less b/src/LayerTree/LayerTreeNode/LayerTreeNode.less deleted file mode 100644 index 95308a84f9..0000000000 --- a/src/LayerTree/LayerTreeNode/LayerTreeNode.less +++ /dev/null @@ -1,13 +0,0 @@ -.react-geo-layertree-node { - transition: opacity 1s; - - &.out-off-range { - opacity: 0.5; - } -} - -.react-geo-layertree { - .ant-tree-treenode { - width: 100%; - } -} diff --git a/src/LayerTree/LayerTreeNode/LayerTreeNode.spec.tsx b/src/LayerTree/LayerTreeNode/LayerTreeNode.spec.tsx deleted file mode 100644 index f3c464ef56..0000000000 --- a/src/LayerTree/LayerTreeNode/LayerTreeNode.spec.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { mount } from 'enzyme'; -import Tree from 'rc-tree'; -import * as React from 'react'; - -import LayerTreeNode from './LayerTreeNode'; - -describe('', () => { - - const defaultProps = { - inResolutionRange: true, - filterTreeNode: jest.fn() - }; - - it('is defined', () => { - expect(LayerTreeNode).not.toBeUndefined(); - }); - - it('can be rendered (inside a Tree component)', () => { - const Cmp = ( - - - - ); - - const wrapper = mount(Cmp); - expect(wrapper).not.toBeUndefined(); - }); -}); diff --git a/src/LayerTree/LayerTreeNode/LayerTreeNode.tsx b/src/LayerTree/LayerTreeNode/LayerTreeNode.tsx deleted file mode 100644 index 45ac10faa3..0000000000 --- a/src/LayerTree/LayerTreeNode/LayerTreeNode.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Tree } from 'antd'; -import * as React from 'react'; -const TreeNode = Tree.TreeNode; - -import './LayerTreeNode.less'; - -import { AntTreeNodeProps } from 'antd/lib/tree'; - -import { CSS_PREFIX } from '../../constants'; - -export interface BaseProps { - inResolutionRange?: boolean; -} - -export type LayerTreeNodeProps = BaseProps & AntTreeNodeProps; - -/** - * Class representing a layer tree node - */ -class LayerTreeNode extends React.PureComponent { - - static isTreeNode: number; - - /** - * The render function. - * - * @return The element. - */ - render() { - const { - inResolutionRange, - children, - icon, - ...passThroughProps - } = this.props; - - const isFolder = Array.isArray(children) && children.length > 0; - let addClassName = (inResolutionRange ? 'within' : 'out-off') + '-range'; - addClassName += isFolder ? ' tree-folder' : ' tree-leaf'; - const finalClassname = `${CSS_PREFIX}layertree-node ${addClassName}`; - - return ( - - {children} - - ); - } - -} - -// Otherwise rc-tree wouldn't recognize this component as TreeNode, see -// https://github.com/react-component/tree/blob/master/src/TreeNode.jsx#L543 -LayerTreeNode.isTreeNode = 1; - -export default LayerTreeNode; diff --git a/src/index.ts b/src/index.ts index 3941212fd9..b55de81fca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,7 +26,6 @@ import FeatureGrid from './Grid/FeatureGrid/FeatureGrid'; import PropertyGrid from './Grid/PropertyGrid/PropertyGrid'; import LayerSwitcher from './LayerSwitcher/LayerSwitcher'; import LayerTree from './LayerTree/LayerTree'; -import LayerTreeNode from './LayerTree/LayerTreeNode/LayerTreeNode'; import Legend from './Legend/Legend'; import SearchResultsPanel from './Panel/SearchResultsPanel/SearchResultsPanel'; import TimeLayerSliderPanel from './Panel/TimeLayerSliderPanel/TimeLayerSliderPanel'; @@ -51,7 +50,6 @@ export { LayerSwitcher, LayerTransparencySlider, LayerTree, - LayerTreeNode, Legend, MeasureButton, ModifyButton, diff --git a/styleguide.config.js b/styleguide.config.js index e3cc6fc310..ab7ce89a3d 100644 --- a/styleguide.config.js +++ b/styleguide.config.js @@ -90,9 +90,6 @@ module.exports = { }, { name: 'LayerTree', components: 'src/LayerTree/**/*.tsx' - }, { - name: 'LayerTreeNode', - components: 'src/LayerTreeNode/**/*.tsx' }, { name: 'Legend', components: 'src/Legend/**/*.tsx'