dash-renderer
Version:
render dash components in react
509 lines (451 loc) • 18.1 kB
JavaScript
/* global fetch:true, Promise:true, document:true */
import {
concat,
contains,
has,
intersection,
isEmpty,
keys,
lensPath,
pluck,
reject,
slice,
sort,
type,
union,
view
} from 'ramda';
import {createAction} from 'redux-actions';
import {crawlLayout, hasId} from '../reducers/utils';
import {APP_STATES} from '../reducers/constants';
import {ACTIONS} from './constants';
import cookie from 'cookie';
import {urlBase} from '../utils';
export const updateProps = createAction(ACTIONS('ON_PROP_CHANGE'));
export const setRequestQueue = createAction(ACTIONS('SET_REQUEST_QUEUE'));
export const computeGraphs = createAction(ACTIONS('COMPUTE_GRAPHS'));
export const computePaths = createAction(ACTIONS('COMPUTE_PATHS'));
export const setLayout = createAction(ACTIONS('SET_LAYOUT'));
export const setAppLifecycle = createAction(ACTIONS('SET_APP_LIFECYCLE'));
export const readConfig = createAction(ACTIONS('READ_CONFIG'));
export function hydrateInitialOutputs() {
return function (dispatch, getState) {
triggerDefaultState(dispatch, getState);
dispatch(setAppLifecycle(APP_STATES('HYDRATED')));
}
}
function triggerDefaultState(dispatch, getState) {
const {graphs} = getState();
const {InputGraph} = graphs;
const allNodes = InputGraph.overallOrder();
const inputNodeIds = [];
allNodes.reverse();
allNodes.forEach(nodeId => {
const componentId = nodeId.split('.')[0];
/*
* Filter out the outputs,
* inputs that aren't leaves,
* and the invisible inputs
*/
if (InputGraph.dependenciesOf(nodeId).length > 0 &&
InputGraph.dependantsOf(nodeId).length == 0 &&
has(componentId, getState().paths)
) {
inputNodeIds.push(nodeId);
}
});
reduceInputIds(inputNodeIds, InputGraph).forEach(nodeId => {
const [componentId, componentProp] = nodeId.split('.');
// Get the initial property
const propLens = lensPath(
concat(getState().paths[componentId],
['props', componentProp]
));
const propValue = view(
propLens,
getState().layout
);
dispatch(notifyObservers({
id: componentId,
props: {[componentProp]: propValue}
}));
});
}
export function redo() {
return function (dispatch, getState) {
const history = getState().history;
dispatch(createAction('REDO')());
const next = history.future[0];
// Update props
dispatch(createAction('REDO_PROP_CHANGE')({
itempath: getState().paths[next.id],
props: next.props
}));
// Notify observers
dispatch(notifyObservers({
id: next.id,
props: next.props
}));
}
}
export function undo() {
return function (dispatch, getState) {
const history = getState().history;
dispatch(createAction('UNDO')());
const previous = history.past[history.past.length - 1];
// Update props
dispatch(createAction('UNDO_PROP_CHANGE')({
itempath: getState().paths[previous.id],
props: previous.props
}));
// Notify observers
dispatch(notifyObservers({
id: previous.id,
props: previous.props
}));
}
}
function reduceInputIds(nodeIds, InputGraph) {
/*
* Create input-output(s) pairs,
* sort by number of outputs,
* and remove redudant inputs (inputs that update the same output)
*/
const inputOutputPairs = nodeIds.map(nodeId => ({
input: nodeId,
outputs: InputGraph.dependenciesOf(nodeId)
}));
const sortedInputOutputPairs = sort(
(a, b) => b.outputs.length - a.outputs.length,
inputOutputPairs
);
const uniquePairs = sortedInputOutputPairs.filter((pair, i) => !contains(
pair.outputs,
pluck('outputs', slice(i + 1, Infinity, sortedInputOutputPairs))
));
return pluck('input', uniquePairs);
}
export function notifyObservers(payload) {
return function (dispatch, getState) {
const {
id,
event,
props
} = payload
const {
config,
layout,
graphs,
paths,
requestQueue,
dependenciesRequest
} = getState();
const {EventGraph, InputGraph} = graphs;
/*
* Figure out all of the output id's that depend on this
* event or input.
* This includes id's that are direct children as well as
* grandchildren.
* grandchildren will get filtered out in a later stage.
*/
let outputObservers;
if (event) {
outputObservers = EventGraph.dependenciesOf(`${id}.${event}`);
} else {
const changedProps = keys(props);
outputObservers = [];
changedProps.forEach(propName => {
const node = `${id}.${propName}`
if (!InputGraph.hasNode(node)) {
return;
}
InputGraph.dependenciesOf(node).forEach(outputId => {
outputObservers.push(outputId);
});
});
}
if (isEmpty(outputObservers)) {
return;
}
/*
* There may be several components that depend on this input.
* And some components may depend on other components before
* updating. Get this update order straightened out.
*/
const depOrder = InputGraph.overallOrder();
outputObservers = sort(
(a, b) => depOrder.indexOf(b) - depOrder.indexOf(a),
outputObservers
);
const queuedObservers = [];
outputObservers.forEach(function filterObservers(outputIdAndProp) {
const outputComponentId = outputIdAndProp.split('.')[0];
/*
* before we make the POST to update the output, check
* that the output doesn't depend on any other inputs that
* that depend on the same controller.
* if the output has another input with a shared controller,
* then don't update this output yet.
* when each dependency updates, it'll dispatch its own
* `notifyObservers` action which will allow this
* component to update.
*
* for example, if A updates B and C (A -> [B, C]) and B updates C
* (B -> C), then when A updates, this logic will
* reject C from the queue since it will end up getting updated
* by B.
*
* in this case, B will already be in queuedObservers by the time
* this loop hits C because of the overallOrder sorting logic
*/
/*
* if the output just listens to events, then it won't be in
* the InputGraph
*/
const controllers = (InputGraph.hasNode(outputIdAndProp) ?
InputGraph.dependantsOf(outputIdAndProp) : []);
const controllersInFutureQueue = intersection(
queuedObservers,
controllers
);
/*
* check that the output hasn't been triggered to update already
* by a different input.
*
* for example:
* Grandparent -> [Parent A, Parent B] -> Child
*
* when Grandparent changes, it will trigger Parent A and Parent B
* to each update Child.
* one of the components (Parent A or Parent B) will queue up
* the change for Child. if this update has already been queued up,
* then skip the update for the other component
*/
const controllersInExistingQueue = intersection(
requestQueue, controllers
);
/*
* also check that this observer is actually in the current
* component tree.
* observers don't actually need to be rendered at the moment
* of a controller change.
* for example, perhaps the user has hidden one of the observers
*/
if (
(controllersInFutureQueue.length === 0) &&
(has(outputComponentId, getState().paths)) &&
(controllersInExistingQueue.length === 0)
) {
queuedObservers.push(outputIdAndProp)
}
});
/*
* record the set of output IDs that will eventually need to be
* updated in a queue. not all of these requests will be fired in this
* action
*/
dispatch(setRequestQueue(union(queuedObservers, requestQueue)));
const promises = [];
for (let i = 0; i < queuedObservers.length; i++) {
const outputIdAndProp = queuedObservers[i];
const [outputComponentId, outputProp] = outputIdAndProp.split('.');
/*
* Construct a payload of the input, state, and event.
* For example:
* If the input triggered this update, then:
* {
* inputs: [{'id': 'input1', 'property': 'new value'}],
* state: [{'id': 'state1', 'property': 'existing value'}]
* }
*
* If an event triggered this udpate, then:
* {
* state: [{'id': 'state1', 'property': 'existing value'}],
* event: {'id': 'graph', 'event': 'click'}
* }
*
*/
const payload = {
output: {id: outputComponentId, property: outputProp}
};
if (event) {
payload.event = event;
}
const {inputs, state} = dependenciesRequest.content.find(
dependency => (
dependency.output.id === outputComponentId &&
dependency.output.property === outputProp
)
)
if (inputs.length > 0) {
payload.inputs = inputs.map(inputObject => {
const propLens = lensPath(
concat(paths[inputObject.id],
['props', inputObject.property]
));
return {
id: inputObject.id,
property: inputObject.property,
value: view(propLens, layout)
};
});
}
if (state.length > 0) {
payload.state = state.map(stateObject => {
const propLens = lensPath(
concat(paths[stateObject.id],
['props', stateObject.property]
));
return {
id: stateObject.id,
property: stateObject.property,
value: view(propLens, layout)
};
});
}
promises.push(fetch(`${urlBase(config)}_dash-update-component`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': cookie.parse(document.cookie)._csrf_token
},
credentials: 'same-origin',
body: JSON.stringify(payload)
}).then(function handleResponse(res) {
dispatch({
type: 'lastUpdateComponentRequest',
payload: {status: res.status}
});
// clear this item from the request queue
dispatch(setRequestQueue(
reject(
id => id === outputIdAndProp,
getState().requestQueue
)
));
return res.json().then(function handleJson(data) {
/*
* it's possible that this output item is no longer visible.
* for example, the could still be request running when
* the user switched the chapter
*
* if it's not visible, then ignore the rest of the updates
* to the store
*/
if (!has(outputComponentId, getState().paths)) {
return;
}
// and update the props of the component
const observerUpdatePayload = {
itempath: getState().paths[outputComponentId],
// new prop from the server
props: data.response.props,
source: 'response'
};
dispatch(updateProps(observerUpdatePayload));
dispatch(notifyObservers({
id: outputComponentId,
props: data.response.props
}));
/*
* If the response includes children, then we need to update our
* paths store.
* TODO - Do we need to wait for updateProps to finish?
*/
if (has('children', observerUpdatePayload.props)) {
dispatch(computePaths({
subTree: observerUpdatePayload.props.children,
startingPath: concat(
getState().paths[outputComponentId],
['props', 'children']
)
}));
/*
* if children contains objects with IDs, then we
* need to dispatch a propChange for all of these
* new children components
*/
if (contains(
type(observerUpdatePayload.props.children),
['Array', 'Object']
) && !isEmpty(observerUpdatePayload.props.children)
) {
/*
* TODO: We're just naively crawling
* the _entire_ layout to recompute the
* the dependency graphs.
* We don't need to do this - just need
* to compute the subtree
*/
const newProps = {};
crawlLayout(
observerUpdatePayload.props.children,
function appendIds(child) {
if (hasId(child)) {
keys(child.props).forEach(childProp => {
const inputId = (
`${child.props.id}.${childProp}`
);
if (has(inputId, InputGraph.nodes)) {
newProps[inputId] = ({
id: child.props.id,
props: {
[childProp]: child.props[childProp]
}
});
}
})
}
}
);
/*
* Organize props by shared outputs so that we
* only make one request per output component
* (even if there are multiple inputs).
*/
const reducedNodeIds = reduceInputIds(
keys(newProps), InputGraph);
const depOrder = InputGraph.overallOrder();
const sortedNewProps = sort((a, b) =>
depOrder.indexOf(a) - depOrder.indexOf(b),
reducedNodeIds
);
sortedNewProps.forEach(function(nodeId) {
dispatch(notifyObservers(newProps[nodeId]));
});
}
}
})}));
}
return Promise.all(promises);
}
}
export function serialize(state) {
// Record minimal input state in the url
const {graphs, paths, layout} = state;
const {InputGraph} = graphs;
const allNodes = InputGraph.nodes;
const savedState = {};
keys(allNodes).forEach(nodeId => {
const [componentId, componentProp] = nodeId.split('.');
/*
* Filter out the outputs,
* and the invisible inputs
*/
if (InputGraph.dependenciesOf(nodeId).length > 0 &&
has(componentId, paths)
) {
// Get the property
const propLens = lensPath(
concat(paths[componentId],
['props', componentProp]
));
const propValue = view(
propLens,
layout
);
savedState[nodeId] = propValue;
}
});
return savedState;
}