Adds "feat: serialize platform state to the url" to ukraine-timemap (#59)

Co-authored-by: Felix Spöttel <1682504+fspoettel@users.noreply.github.com>
Co-authored-by: msramalho <19508417+msramalho@users.noreply.github.com>
This commit is contained in:
Logan Williams
2022-10-26 18:46:00 +02:00
committed by GitHub
parent 3c323c6a09
commit 64d6b34469
13 changed files with 27307 additions and 180 deletions

27002
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -407,3 +407,17 @@ export function fetchSourceError(msg) {
msg,
};
}
export const TOGGLE_SATELLITE_VIEW = "TOGGLE_SATELLITE_VIEW";
export function toggleSatelliteView() {
return {
type: TOGGLE_SATELLITE_VIEW,
};
}
export const REHYDRATE_STATE = "REHYDRATE_STATE";
export function rehydrateState() {
return {
type: REHYDRATE_STATE,
};
}

View File

@@ -48,25 +48,6 @@ export function zipColorsToPercentages(colors, percentages) {
}, {});
}
/**
* Get URI params to start with predefined set of
* https://stackoverflow.com/questions/901115/how-can-i-get-query-string-values-in-javascript
* @param {string} name: name of paramater to search
* @param {string} url: url passed as variable, defaults to window.location.href
*/
export function getParameterByName(name, url) {
if (!url) url = window.location.href;
name = name.replace(/[[\]]/g, "\\$&");
const regex = new RegExp(`[?&]${name}(=([^&#]*)|&|#|$)`);
const results = regex.exec(url);
if (!results) return null;
if (!results[2]) return "";
return decodeURIComponent(results[2].replace(/\+/g, " "));
}
/**
* Compare two arrays of scalars
* @param {array} arr1: array of numbers
@@ -597,3 +578,8 @@ export function downloadAsFile(filename, content) {
}
export const isEmptyString = (s) => s.length === 0;
export const isOdd = (num) => num % 2 !== 0;
export function isEmptyObject(o) {
return o == null || (typeof o === "object" && !Object.keys(o).length);
}

View File

@@ -2,6 +2,7 @@ import React from "react";
import { bindActionCreators } from "redux";
import { connect } from "react-redux";
import { isMobileOnly } from "react-device-detect";
import * as actions from "../actions";
import * as selectors from "../selectors";
@@ -23,7 +24,6 @@ import NarrativeControls from "./controls/NarrativeControls.js";
import colors from "../common/global";
import { binarySearch, insetSourceFrom } from "../common/utilities";
import { isMobileOnly } from "react-device-detect";
class Dashboard extends React.Component {
constructor(props) {
@@ -41,12 +41,14 @@ class Dashboard extends React.Component {
}
componentDidMount() {
this.props.actions.fetchDomain().then((domain) =>
this.props.actions.fetchDomain().then((domain) => {
this.props.actions.updateDomain({
domain,
features: this.props.features,
})
);
});
this.props.actions.rehydrateState();
});
// NOTE: hack to get the timeline to always show. Not entirely sure why
// this is necessary.
window.dispatchEvent(new Event("resize"));
@@ -259,21 +261,33 @@ class Dashboard extends React.Component {
) : null;
let searchParams = new URLSearchParams(window.location.href.split("?")[1]);
return (
<Popup
title="Introduction to the platform"
theme="dark"
isOpen={
app.flags.isIntropopup &&
(!searchParams.has("cover") || searchParams.get("cover") !== "false")
}
onClose={actions.toggleIntroPopup}
content={app.intro}
styles={styles}
>
{extraContent}
</Popup>
);
let rememberDismissedIntro =
localStorage.getItem("rememberDismissedIntro") === "true";
let forceShowIntro = searchParams.get("cover") === "true";
if (
(forceShowIntro || !rememberDismissedIntro) &&
!searchParams.has("id")
) {
return (
<Popup
title="Introduction to the platform"
theme="dark"
isOpen={
app.flags.isIntropopup && searchParams.get("cover") !== "false"
}
onClose={() => {
actions.toggleIntroPopup();
localStorage.setItem("rememberDismissedIntro", "true");
}}
content={app.intro}
styles={styles}
>
{extraContent}
</Popup>
);
} else {
return null;
}
}
render() {

View File

@@ -1,13 +1,26 @@
import { combineReducers } from "redux";
import rootReducer from "./root.js";
import domain from "./domain.js";
import app from "./app.js";
import ui from "./ui.js";
import features from "./features.js";
export default combineReducers({
app,
domain,
ui,
features,
});
function decorateRootReducer(rootReducer, reducer) {
return (state, action) =>
reducer(
{
...rootReducer(state, action),
},
action
);
}
export default decorateRootReducer(
rootReducer,
combineReducers({
app,
domain,
ui,
features,
})
);

11
src/reducers/root.js Normal file
View File

@@ -0,0 +1,11 @@
import { REHYDRATE_STATE } from "../actions";
import { applyUrlState } from "../store/plugins/urlState";
export default function rootReducer(state = {}, action) {
switch (action.type) {
case REHYDRATE_STATE:
return applyUrlState(state);
default:
return state;
}
}

View File

@@ -34,6 +34,9 @@ export const getNotifications = (state) => state.domain.notifications;
export const getActiveFilters = (state) => state.app.associations.filters;
export const getActiveCategories = (state) => state.app.associations.categories;
export const getActiveShapes = (state) => state.app.shapes;
export const getColoringSet = (state) => state.app.associations.coloringSet;
export const getTimeRange = (state) => state.app.timeline.range;
export const getTimelineDimensions = (state) => state.app.timeline.dimensions;
export const selectNarrative = (state) => state.app.associations.narrative;
export const getFeatures = (state) => state.features;
export const getEventRadius = (state) => state.ui.eventRadius;
@@ -67,7 +70,6 @@ export const selectRegions = createSelector(
}
);
const getTimeRange = (state) => state.app.timeline.range.current;
const getInitialTimeRange = (state) => state.app.timeline.range.initial;
const getInitialDaysShown = (state) =>
state.app.timeline.range.initialDaysShown;
@@ -75,6 +77,7 @@ export const selectTimeRange = createSelector(
[getTimeRange, getInitialTimeRange, getInitialDaysShown],
(range, initialRange, initialDaysShown) => {
let start, end;
range = range.current;
if (Array.isArray(range) && range.length === 2) {
[start, end] = range;
@@ -97,17 +100,6 @@ export const selectTimeRangeLimits = createSelector(
}
);
const getTimelineDimensions = (state) => state.app.timeline.dimensions;
export const selectDimensions = createSelector(
getTimelineDimensions,
(dimensions) => {
return {
...dimensions,
trackHeight: dimensions.contentHeight - 50, // height of time labels
};
}
);
/**
* Of all available events, selects those that
* 1. fall in time range
@@ -394,3 +386,45 @@ export const selectSelected = createSelector(
return selected.map(insetSourceFrom(sources));
}
);
export const selectDimensions = createSelector(
[getTimelineDimensions],
(dimensions) => {
return {
...dimensions,
trackHeight: dimensions.contentHeight - 50, // height of time labels
};
}
);
export const selectFilterPathToIdMapping = createSelector(
[getFilters],
(filters) => {
return filters.reduce((acc, curr) => {
acc[createFilterPathString(curr)] = curr.id;
return acc;
}, {});
}
);
export const selectActiveColorSets = createSelector(
[getColoringSet, selectFilterPathToIdMapping],
(set, mapping) => {
return set.map((set) => mapFiltersToIds(set, mapping).join(","));
}
);
export const selectActiveFilterIds = createSelector(
[getActiveFilters, selectFilterPathToIdMapping],
(filters, mapping) => {
return mapFiltersToIds(filters, mapping);
}
);
function mapFiltersToIds(arr, filterMapping) {
return arr.reduce((acc, path) => {
const id = filterMapping[path];
if (id) acc.push(id);
return acc;
}, []);
}

View File

@@ -2,11 +2,13 @@ import { createStore, applyMiddleware, compose } from "redux";
import thunk from "redux-thunk";
import rootReducer from "../reducers";
import { urlStateMiddleware } from "./plugins/urlState";
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(
rootReducer,
composeEnhancers(applyMiddleware(thunk))
composeEnhancers(applyMiddleware(thunk), applyMiddleware(urlStateMiddleware))
);
export default store;

View File

@@ -0,0 +1,20 @@
import { isEmptyObject } from "../../../common/utilities";
import { SCHEMA } from "./schema";
import URLState from "./urlState";
export function applyUrlState(state) {
const urlState = new URLState().deserialize();
if (isEmptyObject(urlState)) return state;
const nextState = { ...state };
Object.values(SCHEMA).forEach((s) => {
try {
s.rehydrate(nextState, urlState);
} catch (err) {
console.error(err);
}
});
return nextState;
}

View File

@@ -0,0 +1,2 @@
export { applyUrlState } from "./applyUrlState";
export { urlStateMiddleware } from "./middleware";

View File

@@ -0,0 +1,27 @@
import { SCHEMA } from "./schema";
import URLState from "./urlState";
export function urlStateMiddleware(store) {
return (next) => (action) => {
const result = next(action);
try {
const schemas = Object.values(SCHEMA).filter(
(s) => s.trigger === action.type
);
if (schemas.length) {
const urlState = new URLState();
const state = store.getState();
schemas.forEach((s) => {
urlState.set(s.key, s.dehydrate(state));
});
urlState.serialize();
}
} catch (err) {
console.error("error serializing url state", err);
}
return result;
};
}

View File

@@ -0,0 +1,138 @@
import {
TOGGLE_ASSOCIATIONS,
UPDATE_COLORING_SET,
UPDATE_SELECTED,
UPDATE_TIMERANGE,
} from "../../../actions";
import { ASSOCIATION_MODES } from "../../../common/constants";
import { createFilterPathString } from "../../../common/utilities";
import {
getSelected,
getTimeRange,
selectActiveColorSets,
selectActiveFilterIds,
} from "../../../selectors";
export const SCHEMA_TYPES = {
NUMBER: "NUMBER",
NUMBER_ARRAY: "NUMBER_ARRAY",
STRING: "STRING",
STRING_ARRAY: "STRING_ARRAY",
DATE: "DATE",
DATE_ARRAY: "DATE_ARRAY",
};
export function isSchemaArray(schema) {
return [
SCHEMA_TYPES.DATE_ARRAY,
SCHEMA_TYPES.NUMBER_ARRAY,
SCHEMA_TYPES.STRING_ARRAY,
].includes(schema.type);
}
/**
* Schema specifies how redux state maps to the url and vice versa.
* `trigger`: action that triggers a call to `dehydrate()`
* `type`: type of the mapped URL property
* `dehydrate()`: maps redux state to url state.
* `rehydrate()`:
* maps url state to redux state.
* !for performance reasons, this function works with a mutable ref to `state`!
*/
export const SCHEMA = Object.freeze({
id: {
key: "id",
trigger: UPDATE_SELECTED,
type: SCHEMA_TYPES.STRING_ARRAY,
dehydrate(state) {
return getSelected(state).map(({ civId }) => civId);
},
// TODO: determine time range if `range` not set.
rehydrate(nextState, { id }) {
if (id?.length) {
nextState.app.selected = id.reduce((acc, curr) => {
const event = nextState.domain.events.find((e) => e.civId === curr);
if (event) {
acc.push(event);
} else {
console.warn(
`event ${curr} could not be rehydrated. reason: not present.`
);
}
return acc;
}, []);
}
},
},
range: {
key: "range",
trigger: UPDATE_TIMERANGE,
type: SCHEMA_TYPES.DATE_ARRAY,
dehydrate(state) {
return getTimeRange(state);
},
rehydrate(nextState, { range }) {
if (range?.length === 2) {
const val = Array.from(range);
val.sort((a, b) => new Date(a) - new Date(b));
// HACK! diversion from upstream: we use a custom timeline state format.
nextState.app.timeline = {
...nextState.app.timeline,
range: {
...nextState.app.timeline.range,
current: val,
},
};
}
},
},
filter: {
key: "filter",
trigger: TOGGLE_ASSOCIATIONS,
type: SCHEMA_TYPES.STRING_ARRAY,
dehydrate(state) {
return selectActiveFilterIds(state);
},
// TODO: set parent filters if all children checked.
rehydrate(nextState, { filter }) {
if (filter?.length) {
const filters = nextState.domain.associations.filter(
(x) => x.mode === ASSOCIATION_MODES.FILTER
);
const filterMapping = mapFilterIdsToPaths(filters);
nextState.app.associations.filters = filter.map(
(id) => filterMapping[id]
);
}
},
},
color: {
key: "color",
trigger: UPDATE_COLORING_SET,
type: SCHEMA_TYPES.STRING_ARRAY,
dehydrate(state) {
return selectActiveColorSets(state);
},
// TODO: color parent if all children checked.
rehydrate(state, { color }) {
if (color?.length) {
const filters = state.domain.associations.filter(
(x) => x.mode === ASSOCIATION_MODES.FILTER
);
const filterMapping = mapFilterIdsToPaths(filters);
state.app.associations.coloringSet = color.map((set) =>
set.split(",").map((id) => filterMapping[id])
);
}
},
},
});
function mapFilterIdsToPaths(filters) {
return filters.reduce((acc, curr) => {
acc[curr.id] = createFilterPathString(curr);
return acc;
}, {});
}

View File

@@ -0,0 +1,108 @@
import dayjs from "dayjs";
import { isSchemaArray, SCHEMA, SCHEMA_TYPES } from "./schema";
export class URLState {
constructor() {
this.url = new URL(window.location);
this.schema = SCHEMA;
}
delete(key) {
this.url.searchParams.delete(key);
}
/**
* `key` not declared in `schema` will be ignored.
* `value` is encoded according to the schema.
* if the schema declares `isArray: true`, `value` is required be an array.
*/
set(key, value) {
const schema = this.schema[key];
if (!schema) return;
this.delete(key);
// HACK! diversion from upstream: we use a custom timeline state format.
if (schema.type === SCHEMA_TYPES.DATE_ARRAY) {
value.current.forEach((val) => {
const encoded = this._encode(schema, val);
if (encoded) this.url.searchParams.append(key, encoded);
});
} else if (isSchemaArray(schema)) {
value.forEach((val) => {
const encoded = this._encode(schema, val);
if (encoded) this.url.searchParams.append(key, encoded);
});
} else {
const encoded = this._encode(schema, value);
if (encoded) this.url.searchParams.set(key, encoded);
}
}
serialize() {
window.history.replaceState(null, "", this.url);
}
/**
* Returns URL state as object.
* Values are decoded according to schema.
*/
deserialize() {
const state = {};
this.url.searchParams.forEach((_, key) => {
if (state[key] != null) return;
const schema = this.schema[key];
// ignore unknown query parameters
if (!schema) return;
state[key] = isSchemaArray(schema)
? this.url.searchParams
.getAll(key)
.map((val) => this._decode(schema, val))
: this._decode(schema, this.url.searchParams.get(key));
});
return state;
}
_decode(schema, value) {
switch (schema.type) {
case SCHEMA_TYPES.NUMBER_ARRAY:
case SCHEMA_TYPES.NUMBER: {
return +value;
}
case SCHEMA_TYPES.DATE:
case SCHEMA_TYPES.DATE_ARRAY: {
return new Date(value);
}
default: {
if (value === "null" || value === "undefined") return undefined;
return value;
}
}
}
_encode(schema, value) {
switch (schema.type) {
case SCHEMA_TYPES.NUMBER_ARRAY:
case SCHEMA_TYPES.NUMBER: {
return value.toString();
}
case SCHEMA_TYPES.DATE:
case SCHEMA_TYPES.DATE_ARRAY: {
return dayjs(value).format("YYYY-MM-DD");
}
default: {
return value;
}
}
}
}
export default URLState;