From 570f66604465a455513a4364ddcd474bcd2b3f2a Mon Sep 17 00:00:00 2001 From: Daniel Koch Date: Wed, 28 Feb 2024 14:33:06 +0100 Subject: [PATCH] refactor: class component to function component BREAKING CHANGE: props map and zoomLevel have been removed --- src/Field/ScaleCombo/ScaleCombo.example.md | 72 ++-- src/Field/ScaleCombo/ScaleCombo.spec.tsx | 220 +++++------- src/Field/ScaleCombo/ScaleCombo.tsx | 388 ++++++--------------- 3 files changed, 224 insertions(+), 456 deletions(-) diff --git a/src/Field/ScaleCombo/ScaleCombo.example.md b/src/Field/ScaleCombo/ScaleCombo.example.md index a326d66164..e19e7fc5ad 100644 --- a/src/Field/ScaleCombo/ScaleCombo.example.md +++ b/src/Field/ScaleCombo/ScaleCombo.example.md @@ -2,6 +2,8 @@ This is a example containing a map component and a scale combo ```jsx import ScaleCombo from '@terrestris/react-geo/dist/Field/ScaleCombo/ScaleCombo'; +import MapComponent from '@terrestris/react-util/dist/Components/MapComponent/MapComponent'; +import MapContext from '@terrestris/react-util/dist/Context/MapContext/MapContext'; import OlLayerTile from 'ol/layer/Tile'; import OlMap from 'ol/Map'; import { fromLonLat } from 'ol/proj'; @@ -9,51 +11,37 @@ import OlSourceOSM from 'ol/source/OSM'; import OlView from 'ol/View'; import * as React from 'react'; -class ScaleComboExample extends React.Component { +const ScaleComboExample = () => { - constructor(props) { - - super(props); - - this.mapDivId = `map-${Math.random()}`; - - this.map = new OlMap({ - layers: [ - new OlLayerTile({ - name: 'OSM', - source: new OlSourceOSM() - }) - ], - view: new OlView({ - center: fromLonLat([37.40570, 8.81566]), - zoom: 4 + const map = new OlMap({ + layers: [ + new OlLayerTile({ + name: 'OSM', + source: new OlSourceOSM() }) - }); - } - - componentDidMount() { - this.map.setTarget(this.mapDivId); - } - - render() { - return ( -
-
- -
-
+ ], + view: new OlView({ + center: fromLonLat([37.40570, 8.81566]), + constrainResolution: true, + zoom: 4 + }) + }); + + return ( + +
+
- ); - } + +
+ ); } diff --git a/src/Field/ScaleCombo/ScaleCombo.spec.tsx b/src/Field/ScaleCombo/ScaleCombo.spec.tsx index 019831031f..f4954078b8 100644 --- a/src/Field/ScaleCombo/ScaleCombo.spec.tsx +++ b/src/Field/ScaleCombo/ScaleCombo.spec.tsx @@ -1,182 +1,124 @@ -import MapUtil from '@terrestris/ol-util/dist/MapUtil/MapUtil'; +import { renderInMapContext } from '@terrestris/react-util/dist/Util/rtlTestUtils'; +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; import TestUtil from '../../Util/TestUtil'; import ScaleCombo from './ScaleCombo'; describe('', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + it('is defined', () => { - expect(ScaleCombo).not.toBeUndefined(); + expect(ScaleCombo).toBeDefined(); }); it('can be rendered', () => { - const map = TestUtil.createMap(); - const wrapper = TestUtil.mountComponent(ScaleCombo, { - map - }); - expect(wrapper).not.toBeUndefined(); + const { container } = render(); + expect(container).toBeVisible(); }); - it('passes style prop', () => { + it('calls onZoomLevelSelect when a scale is selected', async () => { + const onZoomLevelSelect = jest.fn(); const map = TestUtil.createMap(); - const props = { - map, - style: { - backgroundColor: 'yellow' - } - }; - const wrapper = TestUtil.mountComponent(ScaleCombo, props); - expect(wrapper.getDOMNode()).toHaveStyle('backgroundColor: yellow'); + renderInMapContext(map, + + ); + + const entry = screen.getByText('1:100'); + + await userEvent.click(entry); + expect(onZoomLevelSelect).toHaveBeenCalledWith('100'); TestUtil.removeMap(map); }); - describe('#getOptionsFromMap', () => { - it('is defined', () => { - const map = TestUtil.createMap(); - const wrapper = TestUtil.mountComponent(ScaleCombo, { - map - }); - const instance = wrapper.instance() as ScaleCombo; - expect(instance.getOptionsFromMap).not.toBeUndefined(); + it('creates options array from given map with resolutions', () => { + const testResolutions = [560, 280, 140, 70, 28]; + const map = TestUtil.createMap({ + resolutions: testResolutions }); - it('creates options array from resolutions set on the map', () => { - const map = TestUtil.createMap(); + renderInMapContext(map, ( + + )); - const getOptionsFromMapSpy = jest.spyOn(ScaleCombo.prototype, 'getOptionsFromMap'); + const options = screen.getAllByText((text, element) => element?.tagName === 'DIV' && /1:/.test(text)); - TestUtil.mountComponent(ScaleCombo, { - map - }); - - expect(getOptionsFromMapSpy).toHaveBeenCalledTimes(1); - getOptionsFromMapSpy.mockRestore(); - - TestUtil.removeMap(map); - }); + expect(options).toHaveLength(testResolutions.length); + TestUtil.removeMap(map); + }); - it('creates options array from given map without resolutions', () => { - const map = TestUtil.createMap(); - const wrapper = TestUtil.mountComponent(ScaleCombo, { - scales: [], - map: map - }); - - // Reset the scales array, as getOptionsFromMap() will be called in - // constructor. - wrapper.setState({scales: []}); - const instance = wrapper.instance() as ScaleCombo; - const scales = instance.getOptionsFromMap(); - expect(scales).toBeInstanceOf(Array); - - TestUtil.removeMap(map); + it('creates options array from given map with filtered resolutions', () => { + const testResolutions = [560, 280, 140, 70, 28, 19, 15, 14, 13, 9]; + const map = TestUtil.createMap({ + resolutions: testResolutions }); - it('creates options array from given map with resolutions', () => { - const testResolutions = [560, 280, 140, 70, 28]; - const map = TestUtil.createMap({ - resolutions: testResolutions - }); - const wrapper = TestUtil.mountComponent(ScaleCombo, { - scales: [], - map: map - }); - - // Reset the scales array, as getOptionsFromMap() will be called in - // constructor. - wrapper.setState({scales: []}); - - const instance = wrapper.instance() as ScaleCombo; - const scales = instance.getOptionsFromMap(); - - expect(scales).toBeInstanceOf(Array); - expect(scales).toHaveLength(testResolutions.length); - - let testResolution = testResolutions[testResolutions.length - 1]; - const roundScale = (Math.round(MapUtil.getScaleForResolution(testResolution ,'m')!)); + const resolutionsFilter = (res: number) => { + return res >= 19 || res <= 13; + }; - expect(scales[0]).toBe(roundScale); + const expectedLength = testResolutions.filter(resolutionsFilter).length; - TestUtil.removeMap(map); - }); + renderInMapContext(map, ( + + )); - it('creates options array from given map with filtered resolutions', () => { - const testResolutions = [560, 280, 140, 70, 28, 19, 15, 14, 13, 9]; - const map = TestUtil.createMap({ - resolutions: testResolutions - }); + const options = screen.getAllByText((text, element) => element?.tagName === 'DIV' && /1:/.test(text)); - // eslint-disable-next-line - const resolutionsFilter = (res: number) => { - return res >= 19 || res <= 13; - }; - - const expectedLength = testResolutions.filter(resolutionsFilter).length; + expect(options).toHaveLength(expectedLength); + TestUtil.removeMap(map); + }); - const wrapper = TestUtil.mountComponent(ScaleCombo, { - map: map, - scales: [], - resolutionsFilter - }); + it('zooms the map to the clicked scale', async () => { + const map = TestUtil.createMap(); - // Reset the scales array, as getOptionsFromMap() will be called in - // constructor. - wrapper.setState({scales: []}); + renderInMapContext(map, + + ); - const instance = wrapper.instance() as ScaleCombo; - const scales = instance.getOptionsFromMap(); - expect(scales).toBeInstanceOf(Array); - expect(scales).toHaveLength(expectedLength); + expect(map.getView().getResolution()).toBeCloseTo(1); - const roundScale = MapUtil.roundScale(MapUtil.getScaleForResolution( - testResolutions[testResolutions.length - 2] ,'m')!); + const entry = screen.getByText('1:300'); - expect(scales[1]).toBe(roundScale); + await userEvent.click(entry); - TestUtil.removeMap(map); - }); + expect(map.getView().getResolution()).toBeCloseTo(0.08); + TestUtil.removeMap(map); }); - describe('#determineOptionKeyForZoomLevel', () => { - it('is defined', () => { - const map = TestUtil.createMap(); - const wrapper = TestUtil.mountComponent(ScaleCombo, { - map - }); - const instance = wrapper.instance() as ScaleCombo; - expect(instance.determineOptionKeyForZoomLevel).not.toBeUndefined(); - }); + it('sets the correct scale on map zoom', () => { + const map = TestUtil.createMap(); - it('returns "undefied" for erronous zoom level or if exceeds number of valid zoom levels ', () => { - const map = TestUtil.createMap(); - const scaleArray = [100, 200, 300]; - const wrapper = TestUtil.mountComponent(ScaleCombo, { - map, - scales: scaleArray - }); + renderInMapContext(map, + + ); - let component = wrapper.instance() as ScaleCombo; - expect(component.determineOptionKeyForZoomLevel(undefined)).toBeUndefined(); - expect(component.determineOptionKeyForZoomLevel(17.123)).toBeUndefined(); - expect(component.determineOptionKeyForZoomLevel(scaleArray.length)).toBeUndefined(); + expect(map.getView().getResolution()).toBeCloseTo(1); - TestUtil.removeMap(map); + act(() => { + map.getView().setZoom(4); }); - it('returns matching key for zoom level', () => { - const map = TestUtil.createMap(); - const scaleArray = [100, 200, 300]; - const wrapper = TestUtil.mountComponent(ScaleCombo, { - map, - scales: scaleArray - }); - const index = 1; - let component = wrapper.instance() as ScaleCombo; - expect(component.determineOptionKeyForZoomLevel(index)).toBe(scaleArray[index].toString()); - - TestUtil.removeMap(map); - }); + const entry = screen.getByText('1:100'); + expect(entry).toBeVisible(); }); - }); diff --git a/src/Field/ScaleCombo/ScaleCombo.tsx b/src/Field/ScaleCombo/ScaleCombo.tsx index 98815128ab..e4a614c2a2 100644 --- a/src/Field/ScaleCombo/ScaleCombo.tsx +++ b/src/Field/ScaleCombo/ScaleCombo.tsx @@ -1,11 +1,9 @@ -import { Select } from 'antd'; -import * as React from 'react'; -const Option = Select.Option; - import './ScaleCombo.less'; -import Logger from '@terrestris/base-util/dist/Logger'; import MapUtil from '@terrestris/ol-util/dist/MapUtil/MapUtil'; +import useMap from '@terrestris/react-util/dist/Hooks/useMap/useMap'; +import { Select } from 'antd'; +import { SelectProps } from 'antd/lib/select'; import _clone from 'lodash/clone'; import _isEmpty from 'lodash/isEmpty'; import _isEqual from 'lodash/isEqual'; @@ -14,314 +12,154 @@ import _isInteger from 'lodash/isInteger'; import _isNil from 'lodash/isNil'; import _isNumber from 'lodash/isNumber'; import _reverse from 'lodash/reverse'; -import OlMap from 'ol/Map'; -import OlMapEvent from 'ol/MapEvent'; +import { ObjectEvent as OlObjectEvent } from 'ol/Object'; import OlView from 'ol/View'; +import React, { + useCallback, + useEffect, + useMemo, + useState +} from 'react'; import { CSS_PREFIX } from '../../constants'; -interface ScaleComboProps { - /** - * A filter function to filter resolutions no options should be created - */ - resolutionsFilter: (item: any, index?: number, resolutions?: number[]) => boolean; - /** - * Set to false to not listen to the map moveend event. - */ - syncWithMap: boolean; - /** - * The scales. - */ - scales: number[]; - /** - * An optional CSS class which should be added. - */ - className?: string; - /** - * The zoomLevel. - */ - zoomLevel?: number; - /** - * The onZoomLevelSelect function. Pass a function if you want something - * different than the resolution of the passed map. - */ +type OwnProps = { + resolutionsFilter?: (item: any, index?: number, resolutions?: number[]) => boolean; + syncWithMap?: boolean; + scales?: number[]; onZoomLevelSelect?: (zoomLevel: string) => void; - /** - * The resolutions. - */ resolutions?: number[]; - /** - * The map - */ - map: OlMap; -} +}; -interface ScaleComboState { - /** - * The zoomLevel. - */ - zoomLevel?: number; - /** - * The onZoomLevelSelect function. Pass a function if you want something - * different than the resolution of the passed map. - */ - onZoomLevelSelect?: (zoomLevel: string) => void; - /** - * The scales. - */ - scales: number[]; -} +export type ScaleComboProps = SelectProps & OwnProps; -/** - * Class representing a scale combo to choose map scale via a dropdown menu. - * - * @class The ScaleCombo - * @extends React.Component - */ -class ScaleCombo extends React.Component { +const defaultClassName = `${CSS_PREFIX}scalecombo`; - /** - * The default props - */ - static defaultProps = { - resolutionsFilter: () => true, - scales: [], - syncWithMap: true - }; +const ScaleCombo: React.FC = ({ + resolutionsFilter = () => true, + syncWithMap = true, + scales = [], + className, + onZoomLevelSelect, + resolutions, + ...passThroughProps +}) => { - /** - * The className added to this component. - * @private - */ - className = `${CSS_PREFIX}scalecombo`; + const [internalZoomLevel, setInternalZoomLevel] = useState(); - /** - * Create a scale combo. - * @constructs ScaleCombo - */ - constructor(props: ScaleComboProps) { - super(props); - - /** - * The default onZoomLevelSelect function sets the resolution of the passed - * map according to the selected Scale. - * - * @param selectedScale The selectedScale. - */ - const defaultOnZoomLevelSelect = (selectedScale: string) => { - const mapView = props.map.getView(); - const calculatedResolution = MapUtil.getResolutionForScale( - parseInt(selectedScale, 10), mapView.getProjection().getUnits() - ); - mapView.setResolution(calculatedResolution); - }; - - this.state = { - zoomLevel: props.zoomLevel || props.map.getView().getZoom(), - onZoomLevelSelect: props.onZoomLevelSelect || defaultOnZoomLevelSelect, - scales: props.scales.length > 0 ? props.scales : this.getOptionsFromMap() - }; - - if (props.syncWithMap) { - props.map.on('moveend', this.zoomListener); - } - } - - /** - * 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: ScaleComboProps, prevState: ScaleComboState) { - if (_isInteger(nextProps.zoomLevel) && - !_isEqual(nextProps.zoomLevel, prevState.zoomLevel)) { - return { - zoomLevel: nextProps.zoomLevel - }; - } + const map = useMap(); - if (_isFunction(nextProps.onZoomLevelSelect) && - !_isEqual(nextProps.onZoomLevelSelect, prevState.onZoomLevelSelect)) { - return { - onZoomLevelSelect: nextProps.onZoomLevelSelect - }; + const getOptionsFromMap = useCallback(() => { + if (!map) { + return []; } - return null; - } - - /** - * Invoked immediately after updating occurs. This method is not called for - * the initial render. - * - * @param prevProps The previous props. - */ - componentDidUpdate(prevProps: ScaleComboProps) { - const { - map, - syncWithMap - } = this.props; + const optionScales: number[] = []; + const view = map.getView(); + // use existing resolutions array if exists + const viewResolutions = view.getResolutions(); + const pushScale = (s: number[], r: number, v: OlView) => { + const scale = MapUtil.getScaleForResolution(r, v.getProjection().getUnits()); + if (!scale) { + return; + } + const roundScale = MapUtil.roundScale(scale); + if (optionScales.includes(roundScale) ) { + return; + } + optionScales.push(roundScale); + }; - if (!_isEqual(syncWithMap, prevProps.syncWithMap)) { - if (syncWithMap) { - map.on('moveend', this.zoomListener); - } else { - map.un('moveend', this.zoomListener); + if (_isEmpty(viewResolutions) || _isNil(viewResolutions)) { + for (let currentZoomLevel = view.getMaxZoom(); currentZoomLevel >= view.getMinZoom(); currentZoomLevel--) { + const resolution = view.getResolutionForZoom(currentZoomLevel); + if (resolutionsFilter(resolution)) { + pushScale(optionScales, resolution, view); + } } + } else { + const reversedResolutions = _reverse(_clone(viewResolutions)); + reversedResolutions + .filter(resolutionsFilter) + .forEach(resolution => pushScale(scales, resolution, view)); } - } - /** - * Set the zoomLevel of the to the ScaleCombo. - * - * @param evt The 'moveend' event - * @private - */ - zoomListener = (evt: OlMapEvent) => { - const zoom = (evt.target as OlMap).getView().getZoom(); + return optionScales; + }, [map, resolutionsFilter, scales]); + + const zoomListener = useCallback((evt: OlObjectEvent) => { + const zoom = (evt.target as OlView).getZoom(); let roundZoom = 0; if (_isNumber(zoom)) { roundZoom = Math.round(zoom); } - this.setState({ - zoomLevel: roundZoom - }); - }; + setInternalZoomLevel(roundZoom); + }, []); + + const internalScales = useMemo(() => { + return scales.length > 0 ? scales : getOptionsFromMap(); + }, [scales, getOptionsFromMap]); - /** - * @function pushScaleOption: Helper function to create a {@link Option} scale component - * based on a resolution and the {@link OlView} - * - * @param scales The scales array to push the scale to. - * @param resolution map cresolution to generate the option for - * @param view The map view - * - */ - pushScale = (scales: number[], resolution: number, view: OlView) => { - const scale = MapUtil.getScaleForResolution(resolution, view.getProjection().getUnits()); - if (!scale) { + useEffect(() => { + if (!map) { return; } - const roundScale = MapUtil.roundScale(scale); - if (scales.includes(roundScale) ) { - return; + + if (syncWithMap) { + map.getView().on('change:resolution', zoomListener); + } else { + map.getView().un('change:resolution', zoomListener); } - scales.push(roundScale); - }; + }, [map, syncWithMap, zoomListener]); - /** - * Generates the scales to add as {@link Option} to the SelectField based on - * the given instance of {@link OlMap}. - * - * @return The array of scales. - */ - getOptionsFromMap() { - const { - map, - resolutionsFilter - } = this.props; + useEffect(() => { + setInternalZoomLevel(map?.getView().getZoom()); + }, [map]); + const onZoomLevelSelectInternal = (selectedScale: string) => { if (!map) { - Logger.warn('Map component not found. Could not initialize options array.'); - return []; + return; } - const scales: number[] = []; - const view = map.getView(); - // use existing resolutions array if exists - const resolutions = view.getResolutions(); - - if (_isEmpty(resolutions) || _isNil(resolutions)) { - for (let currentZoomLevel = view.getMaxZoom(); currentZoomLevel >= view.getMinZoom(); currentZoomLevel--) { - const resolution = view.getResolutionForZoom(currentZoomLevel); - if (resolutionsFilter(resolution)) { - this.pushScale(scales, resolution, view); - } - } + if (onZoomLevelSelect) { + onZoomLevelSelect(selectedScale); } else { - const reversedResolutions = _reverse(_clone(resolutions)); - reversedResolutions - .filter(resolutionsFilter) - .forEach((resolution) => { - this.pushScale(scales, resolution, view); - }); + // The default. + const mapView = map.getView(); + const calculatedResolution = MapUtil.getResolutionForScale( + parseInt(selectedScale, 10), mapView.getProjection().getUnits() + ); + mapView.setResolution(calculatedResolution); } + }; - return scales; - } - - /** - * Determine option element for provided zoom level out of array of valid options. - * - * @param zoom zoom level - * - * @return Option element for provided zoom level - */ - determineOptionKeyForZoomLevel = (zoom?: number): string | undefined => { - if (_isNil(zoom)) { - return undefined; - } - if (!_isInteger(zoom) || (this.state.scales.length - 1 - zoom) < 0) { + const determineOptionKeyForZoomLevel = (zoom: number): string | undefined => { + if (!_isInteger(zoom) || (internalScales.length - 1 - zoom) < 0) { return undefined; } - return this.state.scales[this.state.scales.length - 1 - zoom].toString(); + return internalScales[internalScales.length - 1 - zoom].toString(); }; - /** - * The render function. - */ - render() { - const { - map, - className, - onZoomLevelSelect: onZoomLevelSelectProp, - resolutions, - resolutionsFilter, - scales: scalesProp, - syncWithMap, - zoomLevel: zoomLevelProp, - ...passThroughProps - } = this.props; - - const { - onZoomLevelSelect, - scales, - zoomLevel - } = this.state; - - const finalClassName = className - ? `${className} ${this.className}` - : this.className; - - const options = scales.map(roundScale => { - return ( - - ); - }); - - return ( - - ); - } -} + const finalClassName = className + ? `${className} ${defaultClassName}` + : defaultClassName; + + return ( +