diff --git a/src/Field/CoordinateReferenceSystemCombo/CoordinateReferenceSystemCombo.example.md b/src/Field/CoordinateReferenceSystemCombo/CoordinateReferenceSystemCombo.example.md index 6b42ef2376..432137b5ef 100644 --- a/src/Field/CoordinateReferenceSystemCombo/CoordinateReferenceSystemCombo.example.md +++ b/src/Field/CoordinateReferenceSystemCombo/CoordinateReferenceSystemCombo.example.md @@ -4,6 +4,8 @@ This demonstrates the usage of the CoordinateReferenceSystemCombo. import CoordinateReferenceSystemCombo from '@terrestris/react-geo/dist/Field/CoordinateReferenceSystemCombo/CoordinateReferenceSystemCombo'; import { applyTransform } from 'ol/extent'; +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 { @@ -11,45 +13,42 @@ import { get, getTransform } from 'ol/proj'; -import { register } from 'ol/proj/proj4'; import OlSourceOSM from 'ol/source/OSM'; import OlView from 'ol/View'; +import React, { useEffect, useState } from 'react'; import proj4 from 'proj4'; -import * as React from 'react'; +import { register } from 'ol/proj/proj4'; const predefinedCrsDefinitions = [{ code: '25832', - value: 'ETRS89 / UTM zone 32N', - proj4def: '+proj=utm +zone=32 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs', + name: 'ETRS89 / UTM zone 32N', + proj4: '+proj=utm +zone=32 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs', bbox: [83.92, 6, 38.76, 12] }, { code: '31466', - value: 'DHDN / 3-degree Gauss-Kruger zone 2', - proj4def: '+proj=tmerc +lat_0=0 +lon_0=6 +k=1 +x_0=2500000 +y_0=0 +ellps=bessel ' + + name: 'DHDN / 3-degree Gauss-Kruger zone 2', + proj4: '+proj=tmerc +lat_0=0 +lon_0=6 +k=1 +x_0=2500000 +y_0=0 +ellps=bessel ' + '+towgs84=598.1,73.7,418.2,0.202,0.045,-2.455,6.7 +units=m +no_defs', bbox: [53.81, 5.86, 49.11, 7.5] }, { code: '31467', - value: 'DHDN / 3-degree Gauss-Kruger zone 3', - proj4def: '+proj=tmerc +lat_0=0 +lon_0=9 +k=1 +x_0=3500000 +y_0=0 +ellps=bessel ' + + name: 'DHDN / 3-degree Gauss-Kruger zone 3', + proj4: '+proj=tmerc +lat_0=0 +lon_0=9 +k=1 +x_0=3500000 +y_0=0 +ellps=bessel ' + '+towgs84=598.1,73.7,418.2,0.202,0.045,-2.455,6.7 +units=m +no_defs', bbox: [55.09, 7.5, 47.27, 10.51] }, { code: '4326', - value: 'WGS 84', - proj4def: '+proj=longlat +datum=WGS84 +no_defs', + name: 'WGS 84', + proj4: '+proj=longlat +datum=WGS84 +no_defs', bbox: [90, -180, -90, 180] }]; -class CoordinateReferenceSystemComboExample extends React.Component { - - constructor(props) { +const CoordinateReferenceSystemComboExample = () => { - super(props); - - this.mapDivId = `map-${Math.random()}`; - - this.map = new OlMap({ + const [map, setMap] = useState(); + + useEffect(() => { + setMap(new OlMap({ layers: [ new OlLayerTile({ name: 'OSM', @@ -60,13 +59,13 @@ class CoordinateReferenceSystemComboExample extends React.Component { center: fromLonLat([37.40570, 8.81566]), zoom: 4 }) - }); + })); register(proj4); - } + }, []); - componentDidMount() { - this.map.setTarget(this.mapDivId); + if (!map) { + return null; } /** @@ -76,20 +75,20 @@ class CoordinateReferenceSystemComboExample extends React.Component { * original code of setProjection can be found here: * https://openlayers.org/en/latest/examples/reprojection-by-code.html */ - setProjection(crsObj) { + setProjection = function(crsObj) { const { code, - value, - proj4def, + name, + proj4: proj4str, bbox } = crsObj; - if (code === null || value === null || proj4def === null || bbox === null) { + if (code === null || name === null || proj4str === null || bbox === null) { return; } const newProjCode = 'EPSG:' + code; - proj4.defs(newProjCode, proj4def); + proj4.defs(newProjCode, proj4str); register(proj4); const newProj = get(newProjCode); @@ -102,53 +101,50 @@ class CoordinateReferenceSystemComboExample extends React.Component { const newView = new OlView({ projection: newProj }); - this.map.setView(newView); + map.setView(newView); newView.fit(extent); } - render() { - return ( -
-
+ +
+ + A CoordinateReferenceSystemCombo with autocomplete mode + where CRS are fetched from epsg.io/. + If a CRS is selected (prop onSelect), the projection is + used to perform client-side raster reprojection of OSM layer in map. + + +
+ + +
+ +
+ + A CoordinateReferenceSystemCombo with predefined definitions + of four CRS. Selecting an option does not affect the map. + -
- - A CoordinateReferenceSystemCombo with autocomplete mode - where CRS are fetched from epsg.io/. - If a CRS is selected (prop onSelect), the projection is - used to perform client-side raster reprojection of OSM layer in map. - - -
- - -
- -
- - A CoordinateReferenceSystemCombo with predefined definitions - of four CRS. Selecting an option does not affect the map. - - -
- - {/* A CoordinateReferenceSystemCombo having predefinedCrsDefinitions*/} - -
+
+ + {/* A CoordinateReferenceSystemCombo having predefinedCrsDefinitions*/} +
- ); - } + + ); } diff --git a/src/Field/CoordinateReferenceSystemCombo/CoordinateReferenceSystemCombo.spec.tsx b/src/Field/CoordinateReferenceSystemCombo/CoordinateReferenceSystemCombo.spec.tsx index ce48994dc2..45b4a4f236 100644 --- a/src/Field/CoordinateReferenceSystemCombo/CoordinateReferenceSystemCombo.spec.tsx +++ b/src/Field/CoordinateReferenceSystemCombo/CoordinateReferenceSystemCombo.spec.tsx @@ -1,14 +1,10 @@ -import Logger from '@terrestris/base-util/dist/Logger'; import { actSetTimeout } from '@terrestris/react-util/dist/Util/rtlTestUtils'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { enableFetchMocks, FetchMock } from 'jest-fetch-mock'; import * as React from 'react'; -import { - findAntdDropdownOptionByText, - queryAntdDropdownOption -} from '../../Util/antdTestQueries'; +import { findAntdDropdownOptionByText, queryAntdDropdownOption } from '../../Util/antdTestQueries'; import CoordinateReferenceSystemCombo from '../CoordinateReferenceSystemCombo/CoordinateReferenceSystemCombo'; describe('', () => { @@ -108,51 +104,8 @@ describe('', () => { }); }); - describe('error handling', () => { - it('logs error message', async () => { - const error = new Error('Peter'); - (fetch as FetchMock).mockRejectOnce(error); - - const loggerSpy = jest.spyOn(Logger, 'error'); - - render(); - const combobox = screen.getByRole('combobox'); - await userEvent.type(combobox, 'a'); - - await waitFor(() => { - expect(loggerSpy).toHaveBeenCalled(); - }); - - expect(loggerSpy).toHaveBeenCalledWith('Error while requesting in CoordinateReferenceSystemCombo', error); - - loggerSpy.mockRestore(); - }); - }); - describe('option clicks are handled correctly', () => { - it('calls the onSelect callback with the correct value', async () => { - const onSelect = jest.fn(); - - render(); - - const combobox = screen.getByRole('combobox'); - - await userEvent.type(combobox, 'a'); - - const result = resultMock.results[0]; - const expected = transformedResults[0]; - - const option = await findAntdDropdownOptionByText(`${result.name} (EPSG:${result.code})`); - - // we have to use fireEvent directly instead of `userEvent.click()` because antd is in the way - fireEvent.click(option); - - await waitFor(() => { - expect(onSelect).toBeCalledWith(expected); - }); - }); - it('sets the value of the combobox to the correct value', async () => { const onSelect = jest.fn(); diff --git a/src/Field/CoordinateReferenceSystemCombo/CoordinateReferenceSystemCombo.tsx b/src/Field/CoordinateReferenceSystemCombo/CoordinateReferenceSystemCombo.tsx index f9fbb97df4..505f780d2d 100644 --- a/src/Field/CoordinateReferenceSystemCombo/CoordinateReferenceSystemCombo.tsx +++ b/src/Field/CoordinateReferenceSystemCombo/CoordinateReferenceSystemCombo.tsx @@ -1,35 +1,36 @@ -import { AutoComplete } from 'antd'; -import { AutoCompleteProps } from 'antd/lib/auto-complete'; -import * as React from 'react'; - -const { Option } = AutoComplete; - import './CoordinateReferenceSystemCombo.less'; import Logger from '@terrestris/base-util/dist/Logger'; -import UrlUtil from '@terrestris/base-util/dist/UrlUtil/UrlUtil'; +import { + ProjectionDefinition, + useProjFromEpsgIO +} from '@terrestris/react-util/dist/Hooks/useProjFromEpsgIO/useProjFromEpsgIO'; +import { AutoComplete } from 'antd'; +import { AutoCompleteProps } from 'antd/lib/auto-complete'; +import { DefaultOptionType } from 'antd/lib/select'; +import _find from 'lodash/find'; +import _isEqual from 'lodash/isEqual'; +import _isNil from 'lodash/isNil'; +import React, { FC, useEffect, useMemo, useState } from 'react'; import { CSS_PREFIX } from '../../constants'; -interface CrsDefinition { - value: string; - code: string; -} +const { Option } = AutoComplete; interface OwnProps { /** * The API to query for CRS definitions * default: https://epsg.io */ - crsApiUrl: string; + crsApiUrl?: string; /** * The empty text set if no value is given / provided */ - emptyTextPlaceholderText: string; + emptyTextPlaceholderText?: string; /** * A function */ - onSelect: (crsDefinition: CrsDefinition) => void; + onSelect?: (projectionDefinition: ProjectionDefinition | undefined) => void; /** * An optional CSS class which should be added. */ @@ -38,15 +39,11 @@ interface OwnProps { * An array of predefined crs definitions having at least value (name of * CRS) and code (e.g. EPSG-code of CRS) property */ - predefinedCrsDefinitions?: CrsDefinition[]; -} - -interface CRSComboState { - crsDefinitions: CrsDefinition[]; - value: string|null; + predefinedCrsDefinitions?: Record; } export type CRSComboProps = OwnProps & AutoCompleteProps; +const defaultClassName = `${CSS_PREFIX}coordinatereferencesystemcombo`; /** * Class representing a combo to choose coordinate projection system via a @@ -55,191 +52,112 @@ export type CRSComboProps = OwnProps & AutoCompleteProps; * @class The CoordinateReferenceSystemCombo * @extends React.Component */ -class CoordinateReferenceSystemCombo extends React.Component { - - static defaultProps = { - emptyTextPlaceholderText: 'Please select a CRS', - crsApiUrl: 'https://epsg.io/', - onSelect: () => undefined - }; - - /** - * The className added to this component. - * @private - */ - className = `${CSS_PREFIX}coordinatereferencesystemcombo`; - - /** - * Create a CRS combo. - * @constructs CoordinateReferenceSystemCombo - */ - constructor(props: CRSComboProps) { - super(props); - - this.state = { - crsDefinitions: [], - value: null - }; - } - - /** - * Fetch CRS definitions from epsg.io for given search string - * - * @param searchVal The search string - */ - fetchCrs = async (searchVal: string) => { - const { crsApiUrl } = this.props; - - const queryParameters = { - format: 'json', - q: searchVal - }; - - return fetch(`${crsApiUrl}?${UrlUtil.objectToRequestString(queryParameters)}`) - .then(response => response.json()); - }; +const CoordinateReferenceSystemCombo: FC = ({ + crsApiUrl, + className, + emptyTextPlaceholderText = 'Please select a CRS', + onSelect = () => undefined, + predefinedCrsDefinitions, + ...passThroughOpts +}) => { + + const [projectionDefinitions, setProjectionDefinitions] = useState>({}); + const [searchValue, setSearchValue] = useState(); + const [selected, setSelected] = useState(); /** * This function gets called when the EPSG.io fetch returns an error. * It logs the error to the console. * */ - onFetchError(error: any) { - Logger.error('Error while requesting in CoordinateReferenceSystemCombo', error); - } - - /** - * This function transforms results of EPSG.io - * - * @param json The result object of EPSG.io-API, see where - * https://github.com/klokantech/epsg.io#api-for-results - * @return Array of CRS definitons used in CoordinateReferenceSystemCombo - */ - transformResults = (json: any): CrsDefinition[] => { - const results = json.results; - if (results && results.length > 0) { - return results.map((obj: any) => ({code: obj.code, value: obj.name, proj4def: obj.proj4, bbox: obj.bbox})); - } else { - return []; - } + const onFetchError = (error: any) => { + Logger.error(`Error while requesting in CoordinateReferenceSystemCombo: ${error}`); }; - /** - * This function gets called when the EPSG.io fetch returns an error. - * It logs the error to the console. - * - * @param value The search value. - */ - handleSearch = async (value: string) => { - const { - predefinedCrsDefinitions - } = this.props; - - if (!value || value.length === 0) { - this.setState({ - value, - crsDefinitions: [] - }); - return; - } + const crsObjects = useMemo( + () => predefinedCrsDefinitions || projectionDefinitions, + [projectionDefinitions, predefinedCrsDefinitions] + ); - if (!predefinedCrsDefinitions) { - try { - const result = await this.fetchCrs(value); - this.setState({ - crsDefinitions: this.transformResults(result) - }); - } catch (e) { - this.onFetchError(e); - } - } else { - this.setState({ value }); - } - }; + const epsgIoResults = useProjFromEpsgIO({ + crsApiUrl, + onFetchError, + searchValue + }); + + const getEpsgDescription = (projDefinition: ProjectionDefinition) => + `${projDefinition.name} (EPSG:${projDefinition.code})`; /** * Handles selection of a CRS item in Autocomplete * - * @param value The EPSG code. + * @param _ * @param option The selected OptionData */ - onCrsItemSelect = (value: string, option: any) => { - const { - onSelect, - predefinedCrsDefinitions - } = this.props; - - const { - crsDefinitions - } = this.state; - - const crsObjects = predefinedCrsDefinitions || crsDefinitions; - - const selected = crsObjects.filter(i => i.code === option.key)[0]; - - this.setState({ - value: selected.value - }); - - onSelect(selected); + const onCrsItemSelect = (_: string, option: DefaultOptionType) => { + const selectedProjection = _find(crsObjects, (p: ProjectionDefinition) => p.code === option.code); + setSelected(selectedProjection); + if (!_isNil(selectedProjection)) { + setSearchValue(undefined); + } }; /** - * Tranforms CRS object returned by EPSG.io to antd Option component + * Transform CRS object returned by EPSG.io to antd Option component * - * @param crsObject Single plain CRS object returned by EPSG.io + * @param code The EPSG code of the ProjectionDefinition + * @param projDefinition Single plain CRS object returned by EPSG.io * * @return Option component to render */ - transformCrsObjectsToOptions(crsObject: CrsDefinition) { - const value = `${crsObject.value} (EPSG:${crsObject.code})`; - + const transformCrsObjectsToOptions = ([, projDefinition]: [string, ProjectionDefinition]) => { + const epsgDescription = getEpsgDescription(projDefinition); return ( ); - } - - /** - * The render function. - */ - render() { - const { - className, - emptyTextPlaceholderText, - onSelect, - crsApiUrl, - predefinedCrsDefinitions, - ...passThroughOpts - } = this.props; - - const { - crsDefinitions - } = this.state; + }; - const crsObjects = predefinedCrsDefinitions || crsDefinitions; + const onClear = () => { + setSelected(undefined); + setSearchValue(undefined); + setProjectionDefinitions({}); + }; - const finalClassName = className ? `${className} ${this.className}` : this.className; + useEffect(() => { + if (!_isNil(epsgIoResults) && !_isEqual(epsgIoResults, projectionDefinitions) && _isNil(selected)) { + setProjectionDefinitions(epsgIoResults); + } + }, [epsgIoResults, projectionDefinitions, selected]); - return ( - this.onCrsItemSelect(v, o)} - onChange={(v: string) => this.handleSearch(v)} - placeholder={emptyTextPlaceholderText} - {...passThroughOpts} - > - { - crsObjects.map(this.transformCrsObjectsToOptions) - } - - ); - } -} + useEffect(() => { + if (!_isNil(selected)) { + onSelect(selected); + } + }, [onSelect, selected]); + + const finalClassName = className ? `${defaultClassName} ${className}` : defaultClassName; + + return ( + + { + Object.entries(crsObjects).map(transformCrsObjectsToOptions) + } + + ); +}; export default CoordinateReferenceSystemCombo;