diff --git a/src/util/urlHelpers.js b/src/util/urlHelpers.js index 405abdf5..f5f68528 100644 --- a/src/util/urlHelpers.js +++ b/src/util/urlHelpers.js @@ -1,4 +1,166 @@ -/* eslint-disable import/prefer-default-export */ +import queryString from 'query-string'; +import { types } from './sdkLoader'; -export const createSlug = (str) => -encodeURIComponent(str.toLowerCase().split(' ').join('-')) +const { LatLng, LatLngBounds } = types; + +export const createSlug = str => encodeURIComponent(str.toLowerCase().split(' ').join('-')); + +/** + * Parse float from a string + * + * @param {String} str - string to parse + * + * @return {Number|null} number parsed from the string, null if not a number + */ +export const parseFloatNum = str => { + const num = window.parseFloat(str); + return window.isNaN(num) ? null : num; +}; + +/** + * Encode a location to use in a URL + * + * @param {LatLng} location - location instance to encode + * + * @return {String} location coordinates separated by a comma + */ +export const encodeLatLng = location => `${location.lat},${location.lng}`; + +/** + * Decode a location from a string + * + * @param {String} str - string encoded with `encodeLatLng` + * + * @return {LatLng|null} location instance, null if could not parse + */ +export const decodeLatLng = str => { + const parts = str.split(','); + if (parts.length !== 2) { + return null; + } + const lat = parseFloatNum(parts[0]); + const lng = parseFloatNum(parts[1]); + if (lat === null || lng === null) { + return null; + } + return new LatLng(lat, lng); +}; + +/** + * Encode a location bounds to use in a URL + * + * @param {LatLngBounds} bounds - bounds instance to encode + * + * @return {String} bounds coordinates separated by a comma + */ +export const encodeLatLngBounds = bounds => `${encodeLatLng(bounds.ne)},${encodeLatLng(bounds.sw)}`; + +/** + * Decode a location bounds from a string + * + * @param {String} str - string encoded with `encodeLatLngBounds` + * + * @return {LatLngBounds|null} location bounds instance, null if could not parse + */ +export const decodeLatLngBounds = str => { + const parts = str.split(','); + if (parts.length !== 4) { + return null; + } + const ne = decodeLatLng(`${parts[0]},${parts[1]}`); + const sw = decodeLatLng(`${parts[2]},${parts[3]}`); + if (ne === null || sw === null) { + return null; + } + return new LatLngBounds(ne, sw); +}; + +// Serialise SDK types in given object values into strings +const serialiseSdkTypes = obj => + Object.keys(obj).reduce( + (result, key) => { + const val = obj[key]; + /* eslint-disable no-param-reassign */ + if (val instanceof LatLngBounds) { + result[key] = encodeLatLngBounds(val); + } else if (val instanceof LatLng) { + result[key] = encodeLatLng(val); + } else { + result[key] = val; + } + /* eslint-enable no-param-reassign */ + return result; + }, + {} + ); + +/** + * Serialise given object into a string that can be used in a + * URL. Encode SDK types into a format that can be parsed with `parse` + * defined below. + * + * @param {Object} params - object with strings/numbers/booleans or + * SDK types as values + * + * @return {String} query string with sorted keys and serialised + * values, `undefined` and `null` values are removed + */ +export const stringify = params => { + const serialised = serialiseSdkTypes(params); + const cleaned = Object.keys(serialised).reduce( + (result, key) => { + const val = serialised[key]; + /* eslint-disable no-param-reassign */ + if (val !== null) { + result[key] = val; + } + /* eslint-enable no-param-reassign */ + return result; + }, + {} + ); + return queryString.stringify(cleaned); +}; + +/** + * Parse a URL search query. Converts numeric values into numbers, + * 'true' and 'false' as booleans, and serialised LatLng and + * LatLngBounds into respective instances based on given options. + * + * @param {String} search - query string to parse, optionally with a + * leading '?' or '#' character + * + * @param {Object} options - Options for parsing: + * + * - latlng {Array { + const { latlng = [], latlngBounds = [] } = options; + const params = queryString.parse(search); + return Object.keys(params).reduce( + (result, key) => { + const val = params[key]; + /* eslint-disable no-param-reassign */ + if (latlng.includes(key)) { + result[key] = decodeLatLng(val); + } else if (latlngBounds.includes(key)) { + result[key] = decodeLatLngBounds(val); + } else if (val === 'true') { + result[key] = true; + } else if (val === 'false') { + result[key] = false; + } else { + const num = parseFloatNum(val); + result[key] = num === null ? val : num; + } + /* eslint-enable no-param-reassign */ + return result; + }, + {} + ); +}; diff --git a/src/util/urlHelpers.test.js b/src/util/urlHelpers.test.js new file mode 100644 index 00000000..be439437 --- /dev/null +++ b/src/util/urlHelpers.test.js @@ -0,0 +1,122 @@ +import { types } from './sdkLoader'; +import { + parseFloatNum, + encodeLatLng, + decodeLatLng, + encodeLatLngBounds, + decodeLatLngBounds, + stringify, + parse, +} from './urlHelpers'; + +const { LatLng, LatLngBounds } = types; + +const SPACE = encodeURIComponent(' '); +const COMMA = encodeURIComponent(','); + +describe('urlHelpers', () => { + describe('parseFloatNum()', () => { + it('handles empty value', () => { + expect(parseFloatNum('')).toBeNull(); + }); + + it('handles non-numeric value', () => { + expect(parseFloatNum('abc')).toBeNull(); + }); + + it('handles int value with surrounding whitespace', () => { + expect(parseFloatNum(' 123 \t')).toEqual(123); + }); + + it('handles float value', () => { + expect(parseFloatNum('123.01')).toBeCloseTo(123.01, 2); + }); + + it('handles trailing chars', () => { + expect(parseFloatNum('123abc')).toEqual(123); + }); + }); + + describe('LatLng serialisation', () => { + it('encodes and decodes', () => { + const location = new LatLng(40, 60); + expect(decodeLatLng(encodeLatLng(location))).toEqual(location); + }); + }); + + describe('LatLngBounds serialisation', () => { + it('encodes and decodes', () => { + const bounds = new LatLngBounds(new LatLng(50, 70), new LatLng(30, 50)); + expect(decodeLatLngBounds(encodeLatLngBounds(bounds))).toEqual(bounds); + }); + }); + + describe('stringify()', () => { + it('handles empty params', () => { + expect(stringify({})).toEqual(''); + }); + + it('sorts params', () => { + const params = { b: 'B', c: 'C', a: 'A' }; + expect(stringify(params)).toEqual('a=A&b=B&c=C'); + }); + + it('encodes values', () => { + const params = { + space: 'A and b', + num: 123, + bool: true, + undef: undefined, + nil: null, + }; + expect(stringify(params)).toEqual(`bool=true&num=123&space=A${SPACE}and${SPACE}b`); + }); + + it('encodes SDK types', () => { + const params = { + origin: new LatLng(40, 60), + bounds: new LatLngBounds(new LatLng(50, 70), new LatLng(30, 50)), + }; + const origin = `40${COMMA}60`; + const bounds = `50${COMMA}70${COMMA}30${COMMA}50`; + expect(stringify(params)).toEqual(`bounds=${bounds}&origin=${origin}`); + }); + }); + + describe('parse()', () => { + it('handles empty string', () => { + expect(parse('')).toEqual({}); + }); + + it('handles question mark', () => { + expect(parse('?')).toEqual({}); + }); + + it('decodes values', () => { + const search = `bool1=true&bool2=false&num1=123&num2=-1.01&space=A${SPACE}and${SPACE}b`; + expect(parse(search)).toEqual({ + space: 'A and b', + num1: 123, + num2: -1.01, + bool2: false, + bool1: true, + }); + }); + + it('decodes SDK types', () => { + const origin = `40${COMMA}60`; + const bounds = `50${COMMA}70${COMMA}30${COMMA}50`; + const search = `bounds=${bounds}&origin=${origin}&invalid=a,10&badBounds=true`; + const options = { + latlng: ['origin', 'invalid'], + latlngBounds: ['bounds', 'badBounds'], + }; + expect(parse(search, options)).toEqual({ + origin: new LatLng(40, 60), + invalid: null, + bounds: new LatLngBounds(new LatLng(50, 70), new LatLng(30, 50)), + badBounds: null, + }); + }); + }); +});