From 0e47e02add2cc307419a9b04ec1774c3db7c90b8 Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Tue, 21 Mar 2017 10:46:01 +0200 Subject: [PATCH 01/13] Use .js file suffix --- src/util/{urlHelpers.jsx => urlHelpers.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/util/{urlHelpers.jsx => urlHelpers.js} (100%) diff --git a/src/util/urlHelpers.jsx b/src/util/urlHelpers.js similarity index 100% rename from src/util/urlHelpers.jsx rename to src/util/urlHelpers.js From 513acb8ea2d2d1f4795826d97051f967303368bd Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Tue, 21 Mar 2017 10:47:26 +0200 Subject: [PATCH 02/13] Remove SearchPage saga from the root saga --- src/sagas.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/sagas.js b/src/sagas.js index 6698de74..6d386a36 100644 --- a/src/sagas.js +++ b/src/sagas.js @@ -1,10 +1,9 @@ import { watchAuthInfo, watchAuth } from './ducks/Auth.duck'; -import { watchLoadListings } from './containers/SearchPage/SearchPage.duck'; import { watchSdk } from './ducks/sdk.duck'; const createRootSaga = sdk => function* rootSaga() { - yield [watchAuthInfo(sdk), watchAuth(sdk), watchLoadListings(sdk), watchSdk(sdk)]; + yield [watchAuthInfo(sdk), watchAuth(sdk), watchSdk(sdk)]; }; export default createRootSaga; From 48095242baf1d06a4e7e60be3cdb726f5b2e7475 Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Tue, 21 Mar 2017 10:49:59 +0200 Subject: [PATCH 03/13] Add name to the location autocomplete input for better snapshots --- .../LocationAutocompleteInput/LocationAutocompleteInput.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/LocationAutocompleteInput/LocationAutocompleteInput.js b/src/components/LocationAutocompleteInput/LocationAutocompleteInput.js index b9630b40..c04602a5 100644 --- a/src/components/LocationAutocompleteInput/LocationAutocompleteInput.js +++ b/src/components/LocationAutocompleteInput/LocationAutocompleteInput.js @@ -251,7 +251,7 @@ class LocationAutocompleteInput extends Component { }); } render() { - const { onFocus, onBlur } = this.props.input; + const { name, onFocus, onBlur } = this.props.input; const { search, predictions } = currentValue(this.props); const handleOnFocus = e => { @@ -276,6 +276,7 @@ class LocationAutocompleteInput extends Component { Date: Wed, 22 Mar 2017 12:15:14 +0200 Subject: [PATCH 04/13] Add query-string package --- package.json | 1 + yarn.lock | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index a9464292..f6c04bc3 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "lodash": "^4.17.4", "path-to-regexp": "^1.5.3", "qs": "^6.4.0", + "query-string": "^4.3.2", "react": "^15.4.2", "react-dom": "^15.4.2", "react-helmet": "^4.0.0", diff --git a/yarn.lock b/yarn.lock index e884cea2..5ffcb097 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5299,7 +5299,7 @@ qs@~6.3.0: version "6.3.1" resolved "https://registry.yarnpkg.com/qs/-/qs-6.3.1.tgz#918c0b3bcd36679772baf135b1acb4c1651ed79d" -query-string@^4.1.0: +query-string@^4.1.0, query-string@^4.3.2: version "4.3.2" resolved "https://registry.yarnpkg.com/query-string/-/query-string-4.3.2.tgz#ec0fd765f58a50031a3968c2431386f8947a5cdd" dependencies: From f138fcc0bc2e918b8f429da715c947ce1b032d07 Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Wed, 22 Mar 2017 12:16:50 +0200 Subject: [PATCH 05/13] Add URL param encoding/decoding helpers --- src/util/urlHelpers.js | 168 +++++++++++++++++++++++++++++++++++- src/util/urlHelpers.test.js | 122 ++++++++++++++++++++++++++ 2 files changed, 287 insertions(+), 3 deletions(-) create mode 100644 src/util/urlHelpers.test.js 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, + }); + }); + }); +}); From a224626fcfdae293a03d775fc9524ecf1a237ef8 Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Wed, 22 Mar 2017 14:20:08 +0200 Subject: [PATCH 06/13] Make a proper location search from landing page --- src/app.test.js | 2 + .../LocationAutocompleteInput.css | 4 +- .../LocationAutocompleteInput.js | 10 +++- .../HeroSearchForm/HeroSearchForm.css | 1 - .../HeroSearchForm/HeroSearchForm.js | 14 ++++-- .../HeroSearchForm/HeroSearchForm.test.js | 2 + .../__snapshots__/HeroSearchForm.test.js.snap | 24 +++++---- src/containers/LandingPage/LandingPage.js | 50 +++++++++---------- .../LandingPage/LandingPage.test.js | 4 +- src/translations/en.json | 2 +- 10 files changed, 63 insertions(+), 50 deletions(-) diff --git a/src/app.test.js b/src/app.test.js index 95cc1e08..39eb2cc5 100644 --- a/src/app.test.js +++ b/src/app.test.js @@ -12,9 +12,11 @@ const render = (url, context) => { describe('Application', () => { it('renders in the client without crashing', () => { + window.google = { maps: {} }; const store = configureStore({}); const div = document.createElement('div'); ReactDOM.render(, div); + delete window.google; }); it('renders in the server without crashing', () => { diff --git a/src/components/LocationAutocompleteInput/LocationAutocompleteInput.css b/src/components/LocationAutocompleteInput/LocationAutocompleteInput.css index e18bbbf9..c5f3aa22 100644 --- a/src/components/LocationAutocompleteInput/LocationAutocompleteInput.css +++ b/src/components/LocationAutocompleteInput/LocationAutocompleteInput.css @@ -3,13 +3,13 @@ } .input { - height: 30px; + height: 50px; } .predictions { position: absolute; margin: 0; - top: 30px; + top: 50px; width: 100%; background-color: #fff; border: 1px solid #eee; diff --git a/src/components/LocationAutocompleteInput/LocationAutocompleteInput.js b/src/components/LocationAutocompleteInput/LocationAutocompleteInput.js index c04602a5..774f9ca0 100644 --- a/src/components/LocationAutocompleteInput/LocationAutocompleteInput.js +++ b/src/components/LocationAutocompleteInput/LocationAutocompleteInput.js @@ -251,7 +251,8 @@ class LocationAutocompleteInput extends Component { }); } render() { - const { name, onFocus, onBlur } = this.props.input; + const { className, placeholder, input } = this.props; + const { name, onFocus, onBlur } = input; const { search, predictions } = currentValue(this.props); const handleOnFocus = e => { @@ -274,8 +275,9 @@ class LocationAutocompleteInput extends Component { return (
{ const { className, intl, handleSubmit, pristine, submitting } = props; const addClassName = className ? { className } : {}; - const placeholderMsg = { id: 'HeroSearchForm.placeholder' }; return (