diff --git a/cli/server/getDataset.js b/cli/server/getDataset.js index 1553b112d..1bc433166 100644 --- a/cli/server/getDataset.js +++ b/cli/server/getDataset.js @@ -12,7 +12,9 @@ const setUpGetDatasetHandler = ({datasetsPath}) => { await helpers.sendJson(res, info); } catch (err) { console.trace(err); - return helpers.handleError(res, `couldn't fetch JSONs`, err.message); + // Throw 404 when not available + const errorCode = err.message.endsWith("not in available datasets") ? 404 : 500; + return helpers.handleError(res, `couldn't fetch JSONs`, err.message, errorCode); } }; }; diff --git a/cli/server/getDatasetHelpers.js b/cli/server/getDatasetHelpers.js index e58cfb16d..f125799cb 100644 --- a/cli/server/getDatasetHelpers.js +++ b/cli/server/getDatasetHelpers.js @@ -8,15 +8,16 @@ * */ + const utils = require("../utils"); const queryString = require("query-string"); const path = require("path"); const convertFromV1 = require("./convertJsonSchemas").convertFromV1; const fs = require("fs"); -const handleError = (res, clientMsg, serverMsg="") => { +const handleError = (res, clientMsg, serverMsg="", code=500) => { utils.warn(`${clientMsg} -- ${serverMsg}`); - return res.status(500).type("text/plain").send(clientMsg); + return res.status(code).type("text/plain").send(clientMsg); }; const splitPrefixIntoParts = (url) => url diff --git a/src/actions/loadData.js b/src/actions/loadData.js index 899ca86aa..abb6f0d98 100644 --- a/src/actions/loadData.js +++ b/src/actions/loadData.js @@ -2,7 +2,7 @@ import queryString from "query-string"; import * as types from "./types"; import { getServerAddress } from "../util/globals"; import { goTo404 } from "./navigation"; -import { createStateFromQueryOrJSONs, createTreeTooState } from "./recomputeReduxState"; +import { createStateFromQueryOrJSONs, createTreeTooState, getNarrativePageFromQuery } from "./recomputeReduxState"; import { loadFrequencies } from "./frequencies"; import { fetchJSON, fetchWithErrorHandling } from "../util/serverInteraction"; import { warningNotification, errorNotification } from "./notifications"; @@ -62,7 +62,7 @@ const getDataset = hasExtension("hardcodedDataPaths") ? getHardcodedData : getDa * @returns {Array} [0] {string} url, modified as needed to represent main dataset * [1] {string | undefined} secondTreeUrl, if applicable */ -const collectDatasetFetchUrls = (url) => { +export const collectDatasetFetchUrls = (url) => { let secondTreeUrl; if (url.includes(":")) { const parts = url.replace(/^\//, '') @@ -226,6 +226,89 @@ export const loadSecondTree = (secondTreeUrl, firstTreeUrl) => async (dispatch, dispatch({type: types.TREE_TOO_DATA, ...newState}); }; +function addEndOfNarrativeBlock(narrativeBlocks) { + const lastContentSlide = narrativeBlocks[narrativeBlocks.length-1]; + const endOfNarrativeSlide = Object.assign({}, lastContentSlide, { + __html: undefined, + isEndOfNarrativeSlide: true + }); + narrativeBlocks.push(endOfNarrativeSlide); +} + +const narrativeFetchingErrorNotification = (err, failedTreeName, fallbackTreeName) => { + return errorNotification({ + message: `Error fetching one of the datasets. + Using the YAML-defined dataset (${fallbackTreeName}) instead.`, + details: `Could not fetch dataset "${failedTreeName}". Make sure this dataset exists + and is spelled correctly. + Error details: + status: ${err.status}; + message: ${err.message}` + }); +}; + +const fetchAndCacheNarrativeDatasets = async (dispatch, blocks, query) => { + const jsons = {}; + const startingBlockIdx = getNarrativePageFromQuery(query, blocks); + const startingDataset = blocks[startingBlockIdx].dataset; + const startingTreeName = collectDatasetFetchUrls(startingDataset)[0]; + const landingSlide = { + mainTreeName: startingTreeName, + secondTreeDataset: false, + secondTreeName: false + }; + const treeNames = blocks.map((block) => collectDatasetFetchUrls(block.dataset)[0]); + + // TODO:1050 + // 1. allow frequencies to be loaded for a narrative dataset here + // 2. allow loading dataset for secondTreeName + + // We block and await for the landing dataset + jsons[startingTreeName] = await + getDataset(startingTreeName) + .then((res) => res.json()) + .catch((err) => { + if (startingTreeName !== treeNames[0]) { + dispatch(narrativeFetchingErrorNotification(err, startingTreeName, treeNames[0])); + // Assuming block[0] is the one that was set properly for all legacy narratives + return getDataset(treeNames[0]) + .then((res) => res.json()); + } + throw err; + }); + landingSlide.json = jsons[startingTreeName]; + // Dispatch landing dataset + dispatch({ + type: types.CLEAN_START, + pathnameShouldBe: startingDataset, + ...createStateFromQueryOrJSONs({ + ...landingSlide, + query, + narrativeBlocks: blocks, + dispatch + }) + }); + + // The other datasets are fetched asynchronously + for (const treeName of treeNames) { + // With this there's no need for Set above + jsons[treeName] = jsons[treeName] || + getDataset(treeName) + .then((res) => res.json()) + .catch((err) => { + dispatch(narrativeFetchingErrorNotification(err, treeName, treeNames[0])); + // We fall back to the first (YAML frontmatter) slide's dataset + return jsons[treeNames[0]]; + }); + } + // Dispatch jsons object containing promises corresponding to each fetch to be stored in redux cache. + // They are potentially unresolved. We await them upon retreieving from the cache - see actions/navigation.js. + dispatch({ + type: types.CACHE_JSONS, + jsons + }); +}; + export const loadJSONs = ({url = window.location.pathname, search = window.location.search} = {}) => { return (dispatch, getState) => { @@ -255,10 +338,8 @@ export const loadJSONs = ({url = window.location.pathname, search = window.locat return JSON.parse(err.fileContents); }) .then((blocks) => { - const firstURL = blocks[0].dataset; - const firstQuery = queryString.parse(blocks[0].query); - if (query.n) firstQuery.n = query.n; - return fetchDataAndDispatch(dispatch, firstURL, firstQuery, blocks); + addEndOfNarrativeBlock(blocks); + return fetchAndCacheNarrativeDatasets(dispatch, blocks, query); }) .catch((err) => { console.error("Error obtaining narratives", err.message); diff --git a/src/actions/navigation.js b/src/actions/navigation.js index 436beecec..1c1388757 100644 --- a/src/actions/navigation.js +++ b/src/actions/navigation.js @@ -1,6 +1,7 @@ import queryString from "query-string"; import { createStateFromQueryOrJSONs } from "./recomputeReduxState"; import { PAGE_CHANGE, URL_QUERY_CHANGE_WITH_COMPUTED_STATE } from "./types"; +import { collectDatasetFetchUrls } from "./loadData"; /* Given a URL, what "page" should be displayed? * "page" means the main app, splash page, status page etc @@ -24,17 +25,38 @@ export const chooseDisplayComponentFromURL = (url) => { return "datasetLoader"; // fallthrough }; +/* + * All the Fetch Promises are created before first render. When trying the cache we `await`. + * If the Fetch is not finished, this will wait for it to end. Subsequent awaits will immeditaly return the result. + * For the landing dataset, no problem either because await on a value just returns the value. + */ +const tryCacheThenFetch = async (mainTreeName, secondTreeName, state) => { + if (state.jsonCache && state.jsonCache.jsons && state.jsonCache.jsons[mainTreeName] !== undefined) { + return { + json: await state.jsonCache.jsons[mainTreeName], + secondJson: await state.jsonCache.jsons[secondTreeName] + }; + } + throw new Error("This should not happen given that we start fetching all datasets before rendering"); +}; + /* changes the state of the page and (perhaps) the dataset displayed. -This function is used throughout the app for all navigation to another page, (including braowserBackForward - see function below) -The exception is for navigation requests that specify only the query changes, or that have an identical pathname to that selected. +This function is used throughout the app for all navigation to another page, Note that this function is not pure, in that it may change the URL +The function allows these behaviors: +Case 1. modify the current redux state via a URL query (used in narratives) +Case 2. modify the current redux state by loading a new dataset, but don't reload the page (e.g. remain within the narrative view) +Case 3. load new dataset & start fresh (used when changing dataset via the drop-down in the sidebar). + ARGUMENTS: -(1) path - REQUIRED - the destination path - e.g. "zika" or "flu/..." (does not include query) -(2) query - OPTIONAL (default: undefined) - see below -(3) push - OPTIONAL (default: true) - signals that pushState should be used (has no effect on the reducers) +path - OPTIONAL (default: window.location.pathname) - the destination path - e.g. "zika" or "flu/..." (does not include query) +query - OPTIONAL (default: queryString.parse(window.location.search)) - see below +queryToDisplay - OPTIONAL (default: value of `query` argument) - doesn't affect state, only URL. +push - OPTIONAL (default: true) - signals that pushState should be used (has no effect on the reducers) +changeDatasetOnly - OPTIONAL (default: false) - enables changing datasets while keeping the tree, etc mounted to the DOM (e.g. whilst changing datasets in a narrative). -UNDERSTANDING QUERY (SLIGHTLY CONFUSING) +Note about how this changes the URL and app state according to arguments: This function changes the pathname (stored in the datasets reducer) and modifies the URL pathname and query accordingly in the middleware. But the URL query is not processed further. Because the datasets reducer has changed, the (or whichever display component we're on) will update. @@ -44,12 +66,11 @@ In this way, the URL query is "used". export const changePage = ({ path = undefined, query = undefined, - queryToDisplay = undefined, /* doesn't affect state, only URL. defaults to query unless specified */ + queryToDisplay = undefined, push = true, - changeDataset = true + changeDatasetOnly = false } = {}) => (dispatch, getState) => { const oldState = getState(); - // console.warn("CHANGE PAGE!", path, query, queryToDisplay, push); /* set some defaults */ if (!path) path = window.location.pathname; // eslint-disable-line @@ -58,7 +79,45 @@ export const changePage = ({ /* some booleans */ const pathHasChanged = oldState.general.pathname !== path; - if (changeDataset || pathHasChanged) { + if (!pathHasChanged) { + /* Case 1 (see docstring): the path (dataset) remains the same but the state may be modulated by the query */ + const newState = createStateFromQueryOrJSONs( + { oldState, + query: queryToDisplay, + narrativeBlocks: oldState.narrative.blocks, + dispatch } + ); + // same dispatch as case 2 but the state comes from the query not from a JSON + dispatch({ + type: URL_QUERY_CHANGE_WITH_COMPUTED_STATE, + ...newState, + pushState: push, + query: queryToDisplay + }); + } else if (changeDatasetOnly) { + /* Case 2 (see docstring): the path (dataset) has changed but the we want to remain on the current page and update state with the new dataset */ + const [mainTreeName, secondTreeName] = collectDatasetFetchUrls(path); + tryCacheThenFetch(mainTreeName, secondTreeName, oldState) + .then(({json, secondJson}) => { + const newState = createStateFromQueryOrJSONs({ + json, + secondTreeDataset: secondJson || false, + mainTreeName, + secondTreeName: secondTreeName || false, + narrativeBlocks: oldState.narrative.blocks, + query: queryToDisplay, + dispatch + }); + // same dispatch as case 1 but the state comes from a JSON + dispatch({ + type: URL_QUERY_CHANGE_WITH_COMPUTED_STATE, + ...newState, + pushState: push, + query: queryToDisplay + }); + }); + } else { + /* Case 3 (see docstring): the path (dataset) has changed and we want to change pages and set a new state according to the path */ dispatch({ type: PAGE_CHANGE, path, @@ -66,17 +125,7 @@ export const changePage = ({ pushState: push, query }); - return; } - - /* the path (dataset) remains the same... but the state may be modulated by the query */ - const newState = createStateFromQueryOrJSONs({oldState, query, dispatch}); - dispatch({ - type: URL_QUERY_CHANGE_WITH_COMPUTED_STATE, - ...newState, - pushState: push, - query: queryToDisplay - }); }; /* a 404 uses the same machinery as changePage, but it's not a thunk. diff --git a/src/actions/recomputeReduxState.js b/src/actions/recomputeReduxState.js index 70baccd48..e4c67c631 100644 --- a/src/actions/recomputeReduxState.js +++ b/src/actions/recomputeReduxState.js @@ -44,6 +44,7 @@ export const getMaxCalDateViaTree = (nodes) => { /* need a (better) way to keep the queryParams all in "sync" */ const modifyStateViaURLQuery = (state, query) => { + // console.log("modify state via URL query", query) if (query.l) { state["layout"] = query.l; } @@ -590,7 +591,7 @@ const modifyControlsViaTreeToo = (controls, name) => { const convertColoringsListToDict = (coloringsList) => { const colorings = {}; coloringsList.forEach((coloring) => { - colorings[coloring.key] = coloring; + colorings[coloring.key] = { ...coloring }; delete colorings[coloring.key].key; }); return colorings; @@ -659,6 +660,16 @@ const createMetadataStateFromJSON = (json) => { return metadata; }; +export const getNarrativePageFromQuery = (query, narrative) => { + let n = parseInt(query.n, 10) || 0; + /* If the query has defined a block which doesn't exist then default to n=0 */ + if (n >= narrative.length) { + console.warn(`Attempted to go to narrative page ${n} which doesn't exist`); + n=0; + } + return n; +}; + export const createStateFromQueryOrJSONs = ({ json = false, /* raw json data - completely nuke existing redux state */ secondTreeDataset = false, @@ -713,14 +724,8 @@ export const createStateFromQueryOrJSONs = ({ only displaying the page number (e.g. ?n=3), but we can look up what (hidden) URL query this page defines via this information */ if (narrativeBlocks) { - addEndOfNarrativeBlock(narrativeBlocks); narrative = narrativeBlocks; - let n = parseInt(query.n, 10) || 0; - /* If the query has defined a block which doesn't exist then default to n=0 */ - if (n >= narrative.length) { - console.warn(`Attempted to go to narrative page ${n} which doesn't exist`); - n=0; - } + const n = getNarrativePageFromQuery(query, narrative); controls = modifyStateViaURLQuery(controls, queryString.parse(narrative[n].query)); query = n===0 ? {} : {n}; // eslint-disable-line /* If the narrative block in view defines a `mainDisplayMarkdown` section, we @@ -842,12 +847,3 @@ export const createTreeTooState = ({ // } return {tree, treeToo, controls}; }; - -function addEndOfNarrativeBlock(narrativeBlocks) { - const lastContentSlide = narrativeBlocks[narrativeBlocks.length-1]; - const endOfNarrativeSlide = Object.assign({}, lastContentSlide, { - __html: undefined, - isEndOfNarrativeSlide: true - }); - narrativeBlocks.push(endOfNarrativeSlide); -} diff --git a/src/actions/types.js b/src/actions/types.js index f33109774..43372220b 100644 --- a/src/actions/types.js +++ b/src/actions/types.js @@ -52,3 +52,4 @@ export const SET_AVAILABLE = "SET_AVAILABLE"; export const TOGGLE_SIDEBAR = "TOGGLE_SIDEBAR"; export const TOGGLE_LEGEND = "TOGGLE_LEGEND"; export const TOGGLE_TRANSMISSION_LINES = "TOGGLE_TRANSMISSION_LINES"; +export const CACHE_JSONS = "CACHE_JSONS"; diff --git a/src/components/entropy/index.js b/src/components/entropy/index.js index b8e859c56..8f477cafa 100644 --- a/src/components/entropy/index.js +++ b/src/components/entropy/index.js @@ -291,6 +291,10 @@ class Entropy extends React.Component { ); } + + componentWillUnmount() { + // TODO:1050 undo all listeners within EntropyChart (ie this.state.chart) + } } const WithTranslation = withTranslation()(Entropy); diff --git a/src/components/main/index.js b/src/components/main/index.js index 8aa27ff41..2d7f7a32e 100644 --- a/src/components/main/index.js +++ b/src/components/main/index.js @@ -38,7 +38,8 @@ const Frequencies = lazy(() => import("../frequencies")); metadataLoaded: state.metadata.loaded, treeLoaded: state.tree.loaded, sidebarOpen: state.controls.sidebarOpen, - showOnlyPanels: state.controls.showOnlyPanels + showOnlyPanels: state.controls.showOnlyPanels, + treeName: state.tree.name })) class Main extends React.Component { constructor(props) { @@ -140,17 +141,17 @@ class Main extends React.Component { renderNarrativeToggle(this.props.dispatch, this.props.displayNarrative) : null } {this.props.displayNarrative || this.props.showOnlyPanels ? null : } - {this.props.panelsToDisplay.includes("tree") ? : null} - {this.props.panelsToDisplay.includes("map") ? : null} + {this.props.panelsToDisplay.includes("tree") ? : null} + {this.props.panelsToDisplay.includes("map") ? : null} {this.props.panelsToDisplay.includes("entropy") ? ( - + ) : null } {this.props.panelsToDisplay.includes("frequencies") && this.props.frequenciesLoaded ? ( - + ) : null } diff --git a/src/components/map/map.js b/src/components/map/map.js index 3ec2b2c5b..0527f4f42 100644 --- a/src/components/map/map.js +++ b/src/components/map/map.js @@ -689,6 +689,10 @@ class Map extends React.Component { ); } + componentWillUnmount() { + this.state.map.off("moveend"); + this.state.map.off("resize"); + } } const WithTranslation = withTranslation()(Map); diff --git a/src/components/narrative/MobileNarrativeDisplay.js b/src/components/narrative/MobileNarrativeDisplay.js index 65cc4b5ec..6e76e90ab 100644 --- a/src/components/narrative/MobileNarrativeDisplay.js +++ b/src/components/narrative/MobileNarrativeDisplay.js @@ -77,7 +77,7 @@ class MobileNarrativeDisplay extends React.Component { }; this._goToPage = (idx) => { - + // TODO:1050 allow multiple dataset narratives on mobile // TODO: this `if` statement should be moved to the `changePage` function or similar if (this.props.blocks[idx] && this.props.blocks[idx].mainDisplayMarkdown) { this.props.dispatch(EXPERIMENTAL_showMainDisplayMarkdown({ @@ -86,7 +86,6 @@ class MobileNarrativeDisplay extends React.Component { })); } else { this.props.dispatch(changePage({ - changeDataset: false, query: queryString.parse(this.props.blocks[idx].query), queryToDisplay: {n: idx}, push: true diff --git a/src/components/narrative/index.js b/src/components/narrative/index.js index 07ddb3985..7e5bea9fb 100644 --- a/src/components/narrative/index.js +++ b/src/components/narrative/index.js @@ -40,7 +40,7 @@ class Narrative extends React.Component { constructor(props) { super(props); this.exitNarrativeMode = () => { - this.props.dispatch(changePage({ path: this.props.blocks[0].dataset, query: true })); + this.props.dispatch(changePage({ path: this.props.blocks[this.props.currentInFocusBlockIdx].dataset, query: true })); }; this.changeAppStateViaBlock = (reactPageScrollerIdx) => { const idx = reactPageScrollerIdx-1; // now same coords as `blockIdx` @@ -52,15 +52,16 @@ class Narrative extends React.Component { })); return; } - - this.props.dispatch(changePage({ - // path: this.props.blocks[blockIdx].dataset, // not yet implemented properly - changeDataset: false, + const change = { query: queryString.parse(this.props.blocks[idx].query), queryToDisplay: {n: idx}, push: true - })); - + }; + if (this.props.blocks[idx].dataset !== this.props.blocks[this.props.currentInFocusBlockIdx].dataset) { + change.path = this.props.blocks[idx].dataset; + change.changeDatasetOnly = true; + } + this.props.dispatch(changePage(change)); }; this.goToNextSlide = () => { if (this.props.currentInFocusBlockIdx === this.props.blocks.length-1) return; // no-op diff --git a/src/reducers/index.js b/src/reducers/index.js index 6f9656e1e..580190947 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -9,6 +9,7 @@ import notifications from "./notifications"; import narrative from "./narrative"; import treeToo from "./treeToo"; import general from "./general"; +import jsonCache from "./jsonCache"; const rootReducer = combineReducers({ metadata, @@ -20,7 +21,8 @@ const rootReducer = combineReducers({ notifications, narrative, treeToo, - general + general, + jsonCache }); export default rootReducer; diff --git a/src/reducers/jsonCache.js b/src/reducers/jsonCache.js new file mode 100644 index 000000000..9165ca5e5 --- /dev/null +++ b/src/reducers/jsonCache.js @@ -0,0 +1,20 @@ +import * as types from "../actions/types"; + +/* the store to cache pre-loaded JSONS */ + +const jsonCache = (state = { + jsons: {} /* object with dataset names as keys and loaded dataset / narrative jsons as values */ +}, action) => { + switch (action.type) { + case types.CACHE_JSONS: + /* Overwrite existing keys in state.jsons with values from + action.jsons and add new keys, values from action.jsons to state.jsons */ + return {jsons: Object.assign(state.jsons, action.jsons)}; + case types.CLEAN_START: + return {jsons: {}}; + default: + return state; + } +}; + +export default jsonCache; diff --git a/src/reducers/metadata.js b/src/reducers/metadata.js index e283116cf..dca825b8f 100644 --- a/src/reducers/metadata.js +++ b/src/reducers/metadata.js @@ -16,6 +16,7 @@ const Metadata = (state = { return Object.assign({}, state, { loaded: false }); + case types.URL_QUERY_CHANGE_WITH_COMPUTED_STATE: case types.CLEAN_START: return action.metadata; case types.ADD_COLOR_BYS: