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();
};