diff --git a/src/components/effectful/request-map-style.js b/src/components/effectful/request-map-style.js new file mode 100644 index 0000000000..8c0f78b9bb --- /dev/null +++ b/src/components/effectful/request-map-style.js @@ -0,0 +1,102 @@ +import {default as Console} from 'global/console'; +import {useEffect} from 'react'; +import {json as requestJson} from 'd3-request'; +import {generateHashId} from 'utils/utils'; + +// Utils +import {isValidStyleUrl, getStyleDownloadUrl} from 'utils/map-style-utils/mapbox-gl-style-editor'; + +// This is exported to aid testing. +export const TASKS = { + loadMapStyleTask, + requestMapStyles +}; + +export default function RequestMapStyle({ + defaultMapStyles, + mapStyles, + loadMapStyles, + mapboxApiAccessToken, + mapboxApiUrl +}) { + useEffect( + () => { + const defaultStyles = Object.values(defaultMapStyles); + // add id to custom map styles if not given + const customStyles = (mapStyles || []).map(ms => ({ + ...ms, + id: ms.id || generateHashId() + })); + + const allStyles = [...customStyles, ...defaultStyles].reduce( + (accu, style) => { + const hasStyleObject = style.style && typeof style.style === 'object'; + accu[hasStyleObject ? 'toLoad' : 'toRequest'][style.id] = style; + + return accu; + }, + {toLoad: {}, toRequest: {}} + ); + loadMapStyles(allStyles.toLoad); + // TODO: Make the side effect here and then call loadMapStyles + TASKS.requestMapStyles(allStyles.toRequest, { + mapboxApiAccessToken, + mapboxApiUrl, + loadMapStyles + }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + // This is intentional. This effect is only called once, on mount. + ] + ); + return null; +} + +function requestMapStyles(mapStyles, {mapboxApiAccessToken, mapboxApiUrl, loadMapStyles}) { + return Promise.all( + Object.values(mapStyles) + .map(({id, url, accessToken}) => ({ + id, + url: isValidStyleUrl(url) + ? getStyleDownloadUrl(url, accessToken || mapboxApiAccessToken, mapboxApiUrl) + : url + })) + .map(TASKS.loadMapStyleTask) + ).then( + // success + results => + loadMapStyles( + results.reduce( + (accu, {id, style}) => ({ + ...accu, + [id]: { + ...mapStyles[id], + style + } + }), + {} + ) + ), + // error + // loadMapStyleErr + e => { + Console.warn('loadMapStyleErr', e); + } + ); +} + +function loadMapStyleTask({url, id}) { + return new Promise((success, error) => + requestJson(url, (err, result) => { + if (err) { + error(err); + } else { + if (!result) { + error(new Error('Map style response is empty')); + } + success({id, style: result}); + } + }) + ); +} diff --git a/src/components/kepler-gl.js b/src/components/kepler-gl.js index d3fe8102d7..e36377ee97 100644 --- a/src/components/kepler-gl.js +++ b/src/components/kepler-gl.js @@ -50,8 +50,8 @@ import ModalContainerFactory from './modal-container'; import PlotContainerFactory from './plot-container'; import NotificationPanelFactory from './notification-panel'; import GeoCoderPanelFactory from './geocoder-panel'; +import RequestMapStyle from './effectful/request-map-style'; -import {generateHashId} from 'utils/utils'; import {validateToken} from 'utils/mapbox-utils'; import {mergeMessages} from 'utils/locale-utils'; @@ -259,7 +259,6 @@ function KeplerGlFactory( componentDidMount() { this._validateMapboxToken(); - this._loadMapStyle(); this._handleResize(this.props); if (typeof this.props.onKeplerGlInitialized === 'function') { this.props.onKeplerGlInitialized(); @@ -332,28 +331,6 @@ function KeplerGlFactory( }); } - _loadMapStyle = () => { - const defaultStyles = Object.values(this.props.mapStyle.mapStyles); - // add id to custom map styles if not given - const customStyles = (this.props.mapStyles || []).map(ms => ({ - ...ms, - id: ms.id || generateHashId() - })); - - const allStyles = [...customStyles, ...defaultStyles].reduce( - (accu, style) => { - const hasStyleObject = style.style && typeof style.style === 'object'; - accu[hasStyleObject ? 'toLoad' : 'toRequest'][style.id] = style; - - return accu; - }, - {toLoad: {}, toRequest: {}} - ); - - this.props.mapStyleActions.loadMapStyles(allStyles.toLoad); - this.props.mapStyleActions.requestMapStyles(allStyles.toRequest); - }; - render() { const { id, @@ -409,6 +386,13 @@ function KeplerGlFactory( }} ref={this.root} > + {!uiState.readOnly && !readOnly && }
diff --git a/test/browser/components/effectful/index.js b/test/browser/components/effectful/index.js new file mode 100644 index 0000000000..2ba3b13a36 --- /dev/null +++ b/test/browser/components/effectful/index.js @@ -0,0 +1 @@ +import './request-map-style-test'; diff --git a/test/browser/components/kepler-gl-test.js b/test/browser/components/kepler-gl-test.js index ffcfb4c179..b336666881 100644 --- a/test/browser/components/kepler-gl-test.js +++ b/test/browser/components/kepler-gl-test.js @@ -21,9 +21,11 @@ import React from 'react'; import test from 'tape'; import {mount} from 'enzyme'; -import {drainTasksForTesting, succeedTaskWithValues} from 'react-palm/tasks'; import configureStore from 'redux-mock-store'; import {Provider} from 'react-redux'; +import {render} from 'react-dom'; +import {act} from 'react-dom/test-utils'; +import sinon from 'sinon'; import coreReducer from 'reducers/core'; import {keplerGlInit} from 'actions/actions'; @@ -38,6 +40,7 @@ import { GeocoderPanelFactory } from 'components'; import NotificationPanelFactory from 'components/notification-panel'; +import {TASKS as RequestMapStyleTasks} from 'components/effectful/request-map-style'; import {ActionTypes} from 'actions'; import {DEFAULT_MAP_STYLES, EXPORT_IMAGE_ID} from 'constants'; @@ -222,41 +225,60 @@ test('Components -> KeplerGl -> Mount -> Split Maps', t => { t.end(); }); -test('Components -> KeplerGl -> Mount -> Load default map style task', t => { +test('Components -> KeplerGl -> Mount -> Load default map style task', async t => { + // setup + const container = document.createElement('div'); + document.body.appendChild(container); + const requestMapStylesStub = sinon.stub(RequestMapStyleTasks, 'requestMapStyles').callThrough(); + const loadMapStylesStub = sinon.stub(RequestMapStyleTasks, 'loadMapStyleTask'); + t.plan(5); + + [ + {id: 'dark', style: {layers: [], name: 'dark'}}, + {id: 'light', style: {layers: [], name: 'light'}}, + {id: 'muted', style: {hello: 'world'}}, + {id: 'muted_night', style: {world: 'hello'}}, + {id: 'satellite', style: {satellite: 'yes'}} + ].forEach((ret, i) => loadMapStylesStub.onCall(i).resolves(ret)); + + const stateSelector = state => state.keplerGl.map; + // mount with empty store const store = mockStore(initialState); - t.doesNotThrow(() => { - mount( + // t.doesNotThrow(async () => { + await act(async () => + render( state.keplerGl.map} + selector={stateSelector} dispatch={store.dispatch} /> - - ); - }, 'Should not throw error when mount KeplerGl'); + , + container + ) + ); + // }, 'Should not throw error when mount KeplerGl'); const actions = store.getActions(); const expectedActions = [ - {type: ActionTypes.LOAD_MAP_STYLES, payload: {}}, - { - type: ActionTypes.REQUEST_MAP_STYLES, - payload: DEFAULT_MAP_STYLES.reduce((accu, curr) => ({...accu, [curr.id]: curr}), {}) - }, - {type: ActionTypes.UPDATE_MAP, payload: {width: 800, height: 800}} + {type: ActionTypes.UPDATE_MAP, payload: {width: 800, height: 800}}, + {type: ActionTypes.LOAD_MAP_STYLES, payload: {}} ]; t.deepEqual( - actions, + requestMapStylesStub.firstCall.args[0], + DEFAULT_MAP_STYLES.reduce((accu, curr) => ({...accu, [curr.id]: curr}), {}), + 'Should call request map styles' + ); + t.deepEqual( + actions.slice(0, 2), expectedActions, 'Should mount kepler.gl and dispatch 2 actions to load map styles' ); const resultState1 = coreReducer(initialCoreState, actions[1]); - const [task1, ...rest] = drainTasksForTesting(); - t.equal(rest.length, 0, 'should dispatch 1 tasks'); const expectedTask = { payload: [ @@ -288,19 +310,14 @@ test('Components -> KeplerGl -> Mount -> Load default map style task', t => { ] }; - t.deepEqual(task1.payload, expectedTask.payload, 'should create task to load map styles'); + t.deepEqual( + loadMapStylesStub.getCalls().map(call => call.args[0]), + expectedTask.payload, + 'should create task to load map styles' + ); t.deepEqual(resultState1, initialCoreState, 'state should be the same'); - const resultState2 = coreReducer( - resultState1, - succeedTaskWithValues(task1, [ - {id: 'dark', style: {layers: [], name: 'dark'}}, - {id: 'light', style: {layers: [], name: 'light'}}, - {id: 'muted', style: {hello: 'world'}}, - {id: 'muted_night', style: {world: 'hello'}}, - {id: 'satellite', style: {satellite: 'yes'}} - ]) - ); + const resultState2 = coreReducer(resultState1, store.getActions()[2]); const expectedStateMapStyles = { dark: { @@ -332,10 +349,29 @@ test('Components -> KeplerGl -> Mount -> Load default map style task', t => { 'should update state with loaded map styles' ); - t.end(); + // teardown + document.body.removeChild(container); + requestMapStylesStub.restore(); + loadMapStylesStub.restore(); }); -test('Components -> KeplerGl -> Mount -> Load custom map style task', t => { +test('Components -> KeplerGl -> Mount -> Load custom map style task', async t => { + // setup + t.plan(4); + const container = document.createElement('div'); + document.body.appendChild(container); + const requestMapStylesStub = sinon.spy(RequestMapStyleTasks, 'requestMapStyles'); + // Intentionally use a promise that never resolves, since we don't care about the task + // TODO: This isn't great since there are calls still waiting for this promise after + // this test is torn down. + let tearDownReject; + const neverResolvedPromise = new Promise((_, reject) => { + tearDownReject = reject; + }); + const loadMapStylesStub = sinon + .stub(RequestMapStyleTasks, 'loadMapStyleTask') + .returns(neverResolvedPromise); + // mount with empty store const store = mockStore(initialState); // mount without id or a kepler.gl state @@ -363,8 +399,10 @@ test('Components -> KeplerGl -> Mount -> Load custom map style task', t => { } ] }; - t.doesNotThrow(() => { - mount( + // t.doesNotThrow(() => { + await act(async () => + render( + // mount( KeplerGl -> Mount -> Load custom map style task', t => { dispatch={store.dispatch} mapStyles={[customStyle1, customStyle2, customStyle3]} /> - - ); - }, 'Should not throw error when mount KeplerGl'); + , + container + ) + ); + // ); + // }, 'Should not throw error when mount KeplerGl'); const actions = store.getActions(); const expectedActions = [ + {type: ActionTypes.UPDATE_MAP, payload: {width: 800, height: 800}}, { type: ActionTypes.LOAD_MAP_STYLES, payload: { milkshake: customStyle2, chai: customStyle3 } - }, - { - type: ActionTypes.REQUEST_MAP_STYLES, - payload: DEFAULT_MAP_STYLES.reduce( - (accu, curr) => ({...accu, [curr.id]: curr, smoothie: customStyle1}), - {} - ) - }, - {type: ActionTypes.UPDATE_MAP, payload: {width: 800, height: 800}} + } ]; + t.deepEqual( + requestMapStylesStub.firstCall.args[0], + DEFAULT_MAP_STYLES.reduce( + (accu, curr) => ({...accu, [curr.id]: curr, smoothie: customStyle1}), + {} + ), + 'Should call request map styles' + ); t.deepEqual( actions, expectedActions, - 'Should mount kepler.gl and dispatch 2 actions to load map styles' + 'Should mount kepler.gl and dispatch 1 action to load map styles' ); - const resultState1 = coreReducer(initialCoreState, actions[0]); + const resultState1 = coreReducer(initialCoreState, actions[1]); const expectedMapStyleState1 = { ...initialCoreState.mapStyle, @@ -425,12 +467,6 @@ test('Components -> KeplerGl -> Mount -> Load custom map style task', t => { 'Should load map style into reducer and create layer groups' ); - // Do not remove this. Necessary for testing flow - // eslint-disable-next-line no-unused-vars - const resultState2 = coreReducer(resultState1, actions[1]); - const [task1, ...rest] = drainTasksForTesting(); - t.equal(rest.length, 0, 'should dispatch 1 tasks'); - const expectedTask = { payload: [ { @@ -466,7 +502,15 @@ test('Components -> KeplerGl -> Mount -> Load custom map style task', t => { ] }; - t.deepEqual(task1.payload, expectedTask.payload, 'should create task to load map styles'); + t.deepEqual( + loadMapStylesStub.getCalls().map(call => call.args[0]), + expectedTask.payload, + 'should create task to load map styles' + ); - t.end(); + // teardown + document.body.removeChild(container); + requestMapStylesStub.restore(); + loadMapStylesStub.restore(); + tearDownReject(); });