diff --git a/src/config/router.js b/src/config/router.js index 0a76ad6d0..888fa129d 100644 --- a/src/config/router.js +++ b/src/config/router.js @@ -1,6 +1,6 @@ import { connectRoutes } from 'redux-first-router'; -import { decodeUrlForState, encodeStateForUrl } from 'utils/stateToUrl'; +import queryState from 'utils/query-state'; import { PAGES } from 'modules/pages/constants'; const routes = PAGES.reduce((acc, page) => ({ @@ -16,9 +16,10 @@ const options = { location: 'router', notFoundPath: `${process.env.REACT_APP_BASE_URL}404`, querySerializer: { - parse: decodeUrlForState, - stringify: encodeStateForUrl + parse: queryState.decode, + stringify: queryState.encode }, + onAfterChange: queryState.inspector, initialDispatch: false }; diff --git a/src/config/sagas.js b/src/config/sagas.js new file mode 100644 index 000000000..10a34bc8e --- /dev/null +++ b/src/config/sagas.js @@ -0,0 +1,26 @@ +import { all, fork } from 'redux-saga/effects'; + +import queryState from 'utils/query-state'; + +import pagesSagas from 'modules/pages/sagas'; +import mapSagas from 'modules/map/sagas'; +import mapStylesSagas from 'modules/map-styles/sagas'; +import layersSagas from 'modules/layers/sagas'; +import widgetsSagas from 'modules/widgets/sagas'; +import locationsSagas from 'modules/locations/sagas'; +import dashboardsSagas from 'modules/dashboards/sagas'; +import languagesSagas from 'modules/languages/sagas'; + +export default function* root() { + yield all([ + fork(queryState.sagas.bind(queryState)), + fork(pagesSagas), + fork(mapSagas), + fork(mapStylesSagas), + fork(layersSagas), + fork(widgetsSagas), + fork(locationsSagas), + fork(dashboardsSagas), + fork(languagesSagas), + ]); +}; \ No newline at end of file diff --git a/src/config/serviceWorker.js b/src/config/serviceWorker.js deleted file mode 100644 index fc41ac627..000000000 --- a/src/config/serviceWorker.js +++ /dev/null @@ -1,136 +0,0 @@ -/* eslint-disable */ -// This optional code is used to register a service worker. -// register() is not called by default. - -// This lets the app load faster on subsequent visits in production, and gives -// it offline capabilities. However, it also means that developers (and users) -// will only see deployed updates on subsequent visits to a page, after all the -// existing tabs open on the page have been closed, since previously cached -// resources are updated in the background. - -// To learn more about the benefits of this model and instructions on how to -// opt-in, read https://bit.ly/CRA-PWA - -const isLocalhost = Boolean( - window.location.hostname === 'localhost' - // [::1] is the IPv6 localhost address. - || window.location.hostname === '[::1]' - // 127.0.0.1/8 is considered localhost for IPv4. - || window.location.hostname.match( - /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ - ) -); - -export function register(config) { - if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { - // The URL constructor is available in all browsers that support SW. - const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); - if (publicUrl.origin !== window.location.origin) { - // Our service worker won't work if PUBLIC_URL is on a different origin - // from what our page is served on. This might happen if a CDN is used to - // serve assets; see https://github.com/facebook/create-react-app/issues/2374 - return; - } - - window.addEventListener('load', () => { - const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; - - if (isLocalhost) { - // This is running on localhost. Let's check if a service worker still exists or not. - checkValidServiceWorker(swUrl, config); - - // Add some additional logging to localhost, pointing developers to the - // service worker/PWA documentation. - navigator.serviceWorker.ready.then(() => { - console.log( - 'This web app is being served cache-first by a service ' - + 'worker. To learn more, visit https://bit.ly/CRA-PWA' - ); - }); - } else { - // Is not localhost. Just register service worker - registerValidSW(swUrl, config); - } - }); - } -} - -function registerValidSW(swUrl, config) { - navigator.serviceWorker - .register(swUrl) - .then((registration) => { - registration.onupdatefound = () => { - const installingWorker = registration.installing; - if (installingWorker == null) { - return; - } - installingWorker.onstatechange = () => { - if (installingWorker.state === 'installed') { - if (navigator.serviceWorker.controller) { - // At this point, the updated precached content has been fetched, - // but the previous service worker will still serve the older - // content until all client tabs are closed. - console.log( - 'New content is available and will be used when all ' - + 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' - ); - - // Execute callback - if (config && config.onUpdate) { - config.onUpdate(registration); - } - } else { - // At this point, everything has been precached. - // It's the perfect time to display a - // "Content is cached for offline use." message. - console.log('Content is cached for offline use.'); - - // Execute callback - if (config && config.onSuccess) { - config.onSuccess(registration); - } - } - } - }; - }; - }) - .catch((error) => { - console.error('Error during service worker registration:', error); - }); -} - -function checkValidServiceWorker(swUrl, config) { - // Check if the service worker can be found. If it can't reload the page. - fetch(swUrl) - .then((response) => { - // Ensure service worker exists, and that we really are getting a JS file. - const contentType = response.headers.get('content-type'); - if ( - response.status === 404 - || (contentType != null && contentType.indexOf('javascript') === -1) - ) { - // No service worker found. Probably a different app. Reload the page. - navigator.serviceWorker.ready.then((registration) => { - registration.unregister().then(() => { - window.location.reload(); - }); - }); - } else { - // Service worker found. Proceed as normal. - registerValidSW(swUrl, config); - } - }) - .catch(() => { - console.log( - 'No internet connection found. App is running in offline mode.' - ); - }); -} - -export function unregister() { - if ('serviceWorker' in navigator) { - navigator.serviceWorker.ready.then((registration) => { - registration.unregister(); - }); - } -} diff --git a/src/config/store.js b/src/config/store.js index 5b8c674a6..8ed02a596 100644 --- a/src/config/store.js +++ b/src/config/store.js @@ -2,9 +2,6 @@ import { createStore, combineReducers, applyMiddleware } from 'redux'; import { composeWithDevTools } from 'redux-devtools-extension/developmentOnly'; import createSagaMiddleware from 'redux-saga'; import { handleModule } from 'vizzuality-redux-tools'; -import { all, fork } from 'redux-saga/effects'; - -// import { PAGES } from 'modules/pages/constants'; import * as pages from 'modules/pages'; import * as map from 'modules/map'; @@ -14,14 +11,9 @@ import * as widgets from 'modules/widgets'; import * as locations from 'modules/locations'; import * as dashboards from 'modules/dashboards'; import * as languages from 'modules/languages'; -// Not actually a module, more like middleware -// import { queryState } from 'modules/query-state'; import router from './router'; - -// queryState.config({ -// routerActions: PAGES.map(p => p.name) -// }); +import sagas from './sagas'; const modules = [ { namespace: 'page', components: pages }, @@ -41,10 +33,6 @@ const { enhancer: routerEnhancer } = router; -// const { -// middleware: queryStateMiddleware -// } = queryState; - const sagaMiddleware = createSagaMiddleware(); const reducers = combineReducers({ @@ -54,30 +42,11 @@ const reducers = combineReducers({ {} ) }); - -const middleware = applyMiddleware( - routerMiddleware, - sagaMiddleware, - // queryStateMiddleware -); - +const middleware = applyMiddleware(routerMiddleware, sagaMiddleware); const enhancers = composeWithDevTools(routerEnhancer, middleware); - const store = createStore(reducers, enhancers); -// todo: add a register for this -sagaMiddleware.run(function* root() { - yield all([ - fork(pages.sagas), - fork(mapStyles.sagas), - fork(layers.sagas), - fork(widgets.sagas), - fork(locations.sagas), - fork(dashboards.sagas), - fork(map.sagas), - fork(languages.sagas), - ]); -}); +sagaMiddleware.run(sagas); initialDispatch(); export default store; diff --git a/src/index.js b/src/index.js index 5344fd185..204703b07 100644 --- a/src/index.js +++ b/src/index.js @@ -2,7 +2,6 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Provider } from 'react-redux'; -import * as serviceWorker from 'config/serviceWorker'; import store from 'config/store'; import Pages from 'components/pages'; @@ -14,9 +13,4 @@ const App = () => ( ); -ReactDOM.render(, document.getElementById('root')); - -// If you want your app to work offline and load faster, you can change -// unregister() to register() below. Note this comes with some pitfalls. -// Learn more about service workers: https://bit.ly/CRA-PWA -serviceWorker.unregister(); +ReactDOM.render(, document.getElementById('root')); \ No newline at end of file diff --git a/src/modules/map/index.js b/src/modules/map/index.js index abab445ad..ebd5b4e32 100644 --- a/src/modules/map/index.js +++ b/src/modules/map/index.js @@ -1,6 +1,34 @@ import * as actions from './actions'; import * as reducers from './reducers'; import initialState from './initial-state'; -import sagas from './sagas'; +import sagas, { restoreMapState } from './sagas'; + +import queryState from 'utils/query-state'; + +/** + * queryState.add register the namespace for url to state actions. + * The name property will become the query param. + * It is suppossed to be semantic: + * + * For namespace 'map' + * encode selector + * after any of these actions are triggered. + * + * For namespace 'map' + * decode trigger + * after all of these actions have happened. +*/ +queryState.add({ + name: 'map', + encode: { + after: [ + actions.setBasemap, + ], + selector: state => ({ basemap: state.map.basemap }) + }, + decode: { + trigger: restoreMapState + } +}); export { actions, initialState, reducers, sagas }; diff --git a/src/modules/map/sagas.js b/src/modules/map/sagas.js index df8f98c78..88fd27d91 100644 --- a/src/modules/map/sagas.js +++ b/src/modules/map/sagas.js @@ -4,7 +4,7 @@ import WebMercatorViewport from 'viewport-mercator-project'; import bbox from '@turf/bbox'; import { currentLocation } from 'modules/locations/selectors'; import { easeCubic } from 'd3-ease'; -import { resetViewport, setViewport } from './actions'; +import { resetViewport, setViewport, setBasemap } from './actions'; function* flyToCurrentLocation() { const state = yield select(); @@ -34,6 +34,22 @@ function* flyToCurrentLocation() { } } +// Part of query state, not normal flow. +// View ./index.js queryState.add for more info. +export function * restoreMapState() { + /** + * A regular selector, it could be on a selectors file with reselect + * or better yet, be created automatically by the package based on registered namespace info. + */ + const basemapSelector = state => (state.router.query + && state.router.query.map + && state.router.query.map.basemap) || null; + const basemap = yield select(basemapSelector); + if (basemap) { + yield(put(setBasemap(basemap))); + } +} + export default function* pages() { yield takeLatest('LOCATIONS/FETCH_SUCCEDED', flyToCurrentLocation); yield takeLatest('LOCATIONS/SET_CURRENT', flyToCurrentLocation); diff --git a/src/modules/query-state/index.js b/src/modules/query-state/index.js deleted file mode 100644 index 78fbb7c56..000000000 --- a/src/modules/query-state/index.js +++ /dev/null @@ -1,78 +0,0 @@ -import Registry from './registry'; -import mask from './mask'; - -class QueryStateManager { - registry = new Registry() - - routerActions = []; - - config({ routerActions }) { - if (routerActions) { - this.routerActions.push(...routerActions); - } - } - - hasQuery = (state, namespace) => Boolean(state[namespace].query); - - isRouterAction(actionType) { - // pageActions could be here but there is no need to repeat each time this is run - return this.routerActions.includes(actionType); - } - - middleware = store => next => (action) => { - const state = store.getState(); - const { modules } = this.registry; - const routerNamespace = 'router'; - - Array.from(modules).forEach(([namespace, module]) => { - const { query: currentQuery } = state[routerNamespace]; - const namespaceState = state[namespace]; - - // On changes encode the state as url - if (module.actions.map(moduleAction => moduleAction.toString()).includes(action.type)) { - const namespaceQuery = module.encodeMap(namespaceState); - - const query = currentQuery - ? { - ...currentQuery, - [namespace]: { ...namespaceQuery } - } - : { - [namespace]: namespaceQuery - }; - - const enhancedRoute = { - type: state[routerNamespace].type, - payload: state[routerNamespace].payload, - query - }; - - store.dispatch(enhancedRoute); - } - - // On route decode the query to state - // This should be run whenever the action is part of router actions - // WE ARE CHANGING APP STATE HERE BE CAREFUL!! - if (this.isRouterAction(action.type) && this.hasQuery(state, routerNamespace)) { - if (currentQuery[namespace]) { - const restoredState = module.decodeMap(currentQuery[namespace]); - state[namespace] = { - ...namespaceState, - ...restoredState - }; - } - } - }); - - - return next(action); - } -} - -const queryState = new QueryStateManager(); - -export { - QueryStateManager, - queryState, - mask -}; diff --git a/src/modules/query-state/mask.js b/src/modules/query-state/mask.js deleted file mode 100644 index 5fe00fa76..000000000 --- a/src/modules/query-state/mask.js +++ /dev/null @@ -1,8 +0,0 @@ -export function mask(obj, mask) { - return mask.reduce((acc, prop) => ({ - ...acc, - [prop]: obj[prop] - }), {}); -} - -export default mask; \ No newline at end of file diff --git a/src/modules/query-state/registry.js b/src/modules/query-state/registry.js deleted file mode 100644 index 965431e93..000000000 --- a/src/modules/query-state/registry.js +++ /dev/null @@ -1,16 +0,0 @@ -class Registry { - modules = new Map() - - triggers = new Set() - - add(name, options) { - const { actions } = options; - const actionStrings = actions.map(action => action.toString()); - - // We only need strings to match - this.triggers = new Set([...this.triggers, ...actionStrings]); - this.modules.set(name, options); - } -} - -export default Registry; diff --git a/src/modules/widgets/index.js b/src/modules/widgets/index.js index b9809dcf0..aba9ce012 100644 --- a/src/modules/widgets/index.js +++ b/src/modules/widgets/index.js @@ -1,6 +1,56 @@ import * as actions from './actions'; import * as reducers from './reducers'; -import sagas from './sagas'; +import sagas, { restoreWidgetsState } from './sagas'; import initialState from './initial-state'; +import { fetchSucceeded } from 'modules/layers/actions'; +import queryState from 'utils/query-state'; + +/** + * queryState.add register the namespace for url to state actions. + * The name property will become the query param. + * It is suppossed to be semantic: + * + * For namespace 'map' + * encode selector + * after any of these actions are triggered. + * + * For namespace 'map' + * decode trigger + * after all of these actions have happened. +*/ +queryState.add({ + name: 'widgets', + encode: { + after: [ + actions.expandAll, + actions.collapseAll, + actions.toggleCollapse, + // actions.toggleActive + ], + selector: state => { + const { widgets: { list } } = state; + const serializedList = list.map(widget => ({ + id: widget.slug, + isCollapsed: widget.isCollapsed || false, + isActive: widget.isActive || false + })); + + return serializedList.reduce((acc, widget) => ({ + ...acc, + [widget.id]: { + isCollapsed: widget.isCollapsed, + isActive: widget.isActive + } + }), {}); + } + }, + decode: { + after: [ + fetchSucceeded + ], + trigger: restoreWidgetsState + } +}); + export { actions, initialState, reducers, sagas }; diff --git a/src/modules/widgets/reducers.js b/src/modules/widgets/reducers.js index 598dfa3db..6c410c4c2 100644 --- a/src/modules/widgets/reducers.js +++ b/src/modules/widgets/reducers.js @@ -1,7 +1,12 @@ import { - fetchRequested, fetchSucceeded, fetchFailed, - collapseAll, expandAll, toggleCollapse, - toggleActive, toggleActiveByLayerId + fetchRequested, + fetchSucceeded, + fetchFailed, + collapseAll, + expandAll, + toggleCollapse, + toggleActive, + toggleActiveByLayerId } from './actions'; export default { diff --git a/src/modules/widgets/sagas.js b/src/modules/widgets/sagas.js index 75e54d6a5..1e7352dde 100644 --- a/src/modules/widgets/sagas.js +++ b/src/modules/widgets/sagas.js @@ -1,9 +1,13 @@ -import { takeLatest, put, call } from 'redux-saga/effects'; +import { all, takeLeading, takeLatest, put, call, select } from 'redux-saga/effects'; import DatasetService from 'services/dataset-service'; -import { fetchRequested, fetchSucceeded, fetchFailed, toggleActiveByLayerId } from './actions'; +import { fetchRequested, fetchSucceeded, fetchFailed, toggleActive, toggleActiveByLayerId } from './actions'; const service = new DatasetService({ entityName: 'widgets' }); +function delay(ms) { + return new Promise(resolve => setTimeout(() => resolve(true), ms)) +} + export function* toggleWidgetActive({ payload }) { yield put(toggleActiveByLayerId({ layerId: payload.id, isActive: payload.isActive })); } @@ -28,6 +32,55 @@ export function* getWidgets() { } } +// Part of query state, not normal flow. +// View ./index.js queryState.add for more info. +export function * restoreWidgetsState() { + /** + * A regular selector, it could be on a selectors file with reselect + * or better yet, be created automatically by the package based on registered namespace info. + */ + function * handler () { + const widgetsSelector = state => ({ + urlWidgets: (state.router.query && state.router.query.widgets) || null, + stateWidgets: state.widgets.list + }); + + const {urlWidgets, stateWidgets} = yield select(widgetsSelector); + + if(urlWidgets) { + const toDispatch = []; + const updatedWidgets = stateWidgets.map(widget => { + const updatedWidget = Object.assign({}, widget); + + if (urlWidgets[widget.slug]) { + const update = urlWidgets[widget.slug]; + + if (update.isActive) { + updatedWidget.isActive = true; + toDispatch.push(put(toggleActive({ + id: widget.id, + layerId: widget.layerId, + isActive: true + }))); + } + + if (update.isCollapsed) { + updatedWidget.isCollapsed = true; + } + } + + return updatedWidget; + }); + + yield put(fetchSucceeded(updatedWidgets)); + yield call(delay, 1500); + yield all(toDispatch); + } + } + + yield takeLeading(fetchSucceeded().type, handler); +} + export default function* widgetsSagas() { yield takeLatest('LAYERS/TOGGLE_ACTIVE', toggleWidgetActive); yield takeLatest('WIDGETS/FETCH_ALL', getWidgets); diff --git a/src/utils/query-state/constants.js b/src/utils/query-state/constants.js new file mode 100644 index 000000000..4eb30e5ce --- /dev/null +++ b/src/utils/query-state/constants.js @@ -0,0 +1,8 @@ +import { createAction } from 'vizzuality-redux-tools'; + +export const ACTIONS = { + STORE_STATE: createAction('__QUERY_STATE/STORE_STATE'), + RESTORE_STATE: createAction('__QUERY_STATE/RESTORE_STATE') +}; + +export default ACTIONS; \ No newline at end of file diff --git a/src/utils/query-state/index.js b/src/utils/query-state/index.js new file mode 100644 index 000000000..835ae7be7 --- /dev/null +++ b/src/utils/query-state/index.js @@ -0,0 +1,121 @@ +import { takeEvery, takeLatest, all, fork, put, select } from 'redux-saga/effects'; +import { decodeUrlForState, encodeStateForUrl } from './stateToUrl'; +import get from 'lodash/get'; +import { redirect } from 'redux-first-router'; + +import ACTIONS from './constants'; + +class QueryStateManager { + /** + * Saves the whole namespace object by name. + */ + registry = new Map(); + /** + * Save actions that trigger query encode and the namespace they refer to. + */ + triggers = new Map(); + /** + * Namespaces stores all registered namespaces + * It is a set for having dedup, we only need to know + * if an url query param is registered. + */ + names = new Set(); + + // These are encoding strategies, maybe we can add more later + decode = decodeUrlForState; + encode = encodeStateForUrl; + + /** + * The inspector sniff through redux requests. + */ + inspector = (dispatch, getState, { action }) => { + // We make it an arrow function to have registry state available + const { kind } = action.meta.location; + + if (kind && kind === 'load') { + dispatch(ACTIONS.RESTORE_STATE()); + } else { + // We assume it is put, so far it have worked + dispatch(ACTIONS.STORE_STATE()); + } + } + + /** + * Add a namespace to the registry for act upon changes. + * @param {string} namespace + */ + add(namespace) { + const {name} = namespace; + const actions = get(namespace, 'encode.after', null); + + if (!actions || actions.length < 1) { + // todo: Throw no-encoding-actions error + return; + } + + this.registry.set(name, namespace); + this.names.add(name); + + actions.forEach(action => { + this.triggers.set(action().type, name); + }); + } + + /** + * This is for redux-sagas... using thunks should work pretty similar though. + */ + *sagas() { + const rules = Array.from(this.triggers.entries()); + + const encodeRules = rules.map(([action, name]) => { + const encodeRule = function * () { + const actionListener = function *() { + const namespace = this.registry.get(name); + const state = yield select(); + const {router} = state; + + yield put(redirect({type: router.type, payload: { + ...router.payload, + query: { + ...router.query, + [name]: namespace.encode.selector(state) + } + }})); + }; + + yield takeLatest(action, actionListener.bind(this)); + }; + + return fork(encodeRule.bind(this)); + }); + + const decodeRule = function * decodeRule() { + function *sub() { + const names = Array.from(this.names); + const triggers = names.map(name => { + const { decode } = this.registry.get(name); + + if (decode && decode.trigger) { + return fork(decode.trigger); + } + + return null; + }); + + yield all(triggers.filter(trigger => Boolean(trigger))); + } + + yield takeEvery(ACTIONS.RESTORE_STATE().type, sub.bind(this)); + }; + + yield all([...encodeRules, fork(decodeRule.bind(this))]); + } +} + +const queryState = new QueryStateManager(); + +export default queryState; +export { + QueryStateManager, + queryState +}; diff --git a/src/utils/stateToUrl.js b/src/utils/query-state/stateToUrl.js similarity index 92% rename from src/utils/stateToUrl.js rename to src/utils/query-state/stateToUrl.js index 472be1409..6f1194fa4 100644 --- a/src/utils/stateToUrl.js +++ b/src/utils/query-state/stateToUrl.js @@ -24,5 +24,5 @@ export const encodeStateForUrl = (params) => { } }); - return queryString.stringify(paramsParsed); + return queryString.stringify(paramsParsed).trim(); };