@dwp/govuk-casa
Version:
Framework for creating basic GOVUK Collect-And-Submit-Applications
424 lines (374 loc) • 13.6 kB
JavaScript
const { Graph } = require('graphlib');
const JourneyContext = require('./JourneyContext.js');
const logger = require('./Logger.js')('class.Plan');
/**
* Will check if the source waypoint has specifically passed validation, i.e
* there is a "null" validation entry for the route source.
*
* @param {object} r Route meta.
* @param {JourneyContext} context Journey Context.
* @returns {boolean} Condition result.
*/
function defaultNextFollow(r, context) {
const { validation: v = {} } = context.toObject();
return Object.prototype.hasOwnProperty.call(v, r.source) && v[r.source] === null;
}
/**
* Will check if the target waypoint (the one we're moving back to) has
* specifically passed validation.
*
* @param {object} r Route meta.
* @param {JourneyContext} context Journey context.
* @returns {boolean} Condition result.
*/
function defaultPrevFollow(r, context) {
const { validation: v = {} } = context.toObject();
return Object.prototype.hasOwnProperty.call(v, r.target) && v[r.target] === null;
}
function validateWaypointId(val) {
if (typeof val !== 'string') {
throw new TypeError(`Expected waypoint id to be a string, got ${typeof val}`);
}
}
function validateRouteName(val) {
if (typeof val !== 'string') {
throw new TypeError(`Expected route name to be a string, got ${typeof val}`);
} else if (!['next', 'prev', 'origin'].includes(val)) {
throw new ReferenceError(`Expected route name to be one of next, prev or origin. Got ${val}`)
}
}
function validateRouteCondition(val) {
if (!(val instanceof Function)) {
throw new TypeError(`Expected route condition to be a function, got ${typeof val}`);
}
}
/**
* Creates a user friendly route structure from a given graph edge which will
* be used in userland. This is the object that will be passed into follow
* functions too as the "route" parameter.
*
* @param {object} dgraph Directed graph instance.
* @param {object} edge Graph edge object.
* @returns {object} Route.
*/
const makeRouteObject = (dgraph, edge) => {
const label = dgraph.edge(edge) || {
vorigin: undefined,
worigin: undefined,
};
return {
source: edge.v,
target: edge.w,
name: edge.name,
label: {
sourceOrigin: label.vorigin,
targetOrigin: label.worigin,
},
};
};
const priv = new WeakMap();
class Plan {
constructor(opts = {}) {
// This is our directed, multigraph representation
const dgraph = new Graph({
directed: true,
multigraph: true,
compound: false,
});
// Add "__origin__" node that acts as the source for all "origin" routes
dgraph.setNode('__origin__');
// Gather options
const options = Object.assign(Object.create(null), {
// When true, the validation state of the source node must be `null` (i.e.
// no validation errors) before any custom route conditions are evaluated.
validateBeforeRouteCondition: false,
}, opts);
Object.freeze(options);
priv.set(this, {
dgraph,
follows: {
next: {},
prev: {},
origin: {},
},
options,
});
}
getOptions() {
return priv.get(this).options;
}
getWaypoints() {
return priv.get(this).dgraph.nodes();
}
containsWaypoint(waypoint) {
return this.getWaypoints().includes(waypoint);
}
getRoutes() {
const self = priv.get(this);
return self.dgraph.edges().map((edge) => makeRouteObject(self.dgraph, edge));
}
getRouteCondition(src, tgt, name) {
return priv.get(this).follows[name][`${src}/${tgt}`];
}
/**
* Return all outward routes (out-edges) from the given waypoint, to the
* optional target waypoint.
*
* @param {string} src Source waypoint.
* @param {string} tgt Target waypoint (optional).
* @returns {Array<object>} Route objects found.
*/
getOutwardRoutes(src, tgt = null) {
const self = priv.get(this);
return self.dgraph.outEdges(src, tgt).map((e) => makeRouteObject(self.dgraph, e));
}
/**
* Return all outward routes (out-edges) from the given waypoint, to the
* optional target waypoint, matching the "prev" name.
*
* @param {string} src Source waypoint.
* @param {string} tgt Target waypoint (optional).
* @returns {Array<object>} Route objects found.
*/
getPrevOutwardRoutes(src, tgt = null) {
return this.getOutwardRoutes(src, tgt).filter((r) => r.name === 'prev');
}
/**
* Get info about all the defined origins.
*
* Each origin is returned as an object in the format:
* {
* originId: '<unique-id-of-the-origin>',
* waypoint: '<the-waypoint-at-which-traversals-start>',
* }
*
* @returns {Array<object>} Origins
*/
getOrigins() {
const self = priv.get(this);
return self.dgraph.outEdges('__origin__').map((e) => ({
originId: self.dgraph.node(e.w).originId,
waypoint: e.w,
}));
}
addOrigin(originId, waypoint, follow) {
// Set up a unique route from __origin__ to this waypoint, and label with
// the origin ID
priv.get(this).dgraph.setNode(waypoint, { originId });
this.setNamedRoute('__origin__', waypoint, 'origin', follow || (() => (true)));
}
addSequence(...waypoints) {
// Setup simple double routes (next/prev) between all waypoints in this list
for (let i = 0, l = waypoints.length - 1; i < l; i += 1) {
this.setRoute(waypoints[i], waypoints[i + 1]);
}
}
setNextRoute(src, tgt, follow) {
return this.setNamedRoute(src, tgt, 'next', follow);
}
setPrevRoute(src, tgt, follow) {
return this.setNamedRoute(src, tgt, 'prev', follow);
}
/**
* Adds both a "next" and "prev" route between the two waypoints.
*
* By default, the "prev" route will use the same "follow" test as the "next"
* route. This makes sense in that in order to get the target, the test must
* have been true, and so to reverse the direction we also need that same test
* to be true.
*
* @param {string} src Source waypoint.
* @param {string} tgt Target waypoint.
* @param {Function} followNext Follow test function.
* @param {Function} followPrev Follow test function.
* @returns {Plan} Self.
*/
setRoute(src, tgt, followNext = undefined, followPrev = undefined) {
this.setNamedRoute(src, tgt, 'next', followNext);
this.setNamedRoute(tgt, src, 'prev', followPrev || followNext);
return this;
}
/**
* Create a named route between two waypoints, and give that route a function
* that determine whether it should be followed during traversal operations.
* Note that the source waypoint must be in a successful validation state
* to be considered for traversal, regardless of what the custom function
* determines.
*
* You can also inform how the plan will be traversed by including origin IDs
* in the src/tgt waypoints. For example:
*
* setNamedRoute("originA:hello", "originB:world");
*
* Note that if you specify an origin in one waypoint, you must specify one in
* the other waypoint too.
*
* @param {string} srcId Source waypoint.
* @param {string} tgtId Target waypoint.
* @param {string} name Name of the route (must be unique for this waypoint pairing).
* @param {Function} follow Test function to determine if route can be followed.
* @returns {Plan} Chain.
*/
setNamedRoute(srcId, tgtId, name, follow) {
const self = priv.get(this);
// Validate
validateWaypointId(srcId);
validateWaypointId(tgtId);
validateRouteName(name);
if (follow !== undefined) {
validateRouteCondition(follow);
}
// Pick out the origin ids from src/tgt waypoint ids
const src = (srcId.match(/^([^:]+:)*(.+)$/) || ['', self.guid, srcId])[2];
const tgt = (tgtId.match(/^([^:]+:)*(.+)$/) || ['', self.guid, tgtId])[2];
const vorigin = (srcId.match(/^([^:]+):.+$/) || ['', self.guid])[1];
const worigin = (tgtId.match(/^([^:]+):.+$/) || ['', self.guid])[1];
// Get routing function name to label edge
const label = follow && follow.name;
// Warn if we're overwriting an existing edge on the same name
if (self.dgraph.hasEdge(src, tgt, name)) {
logger.warn('Setting a route that already exists (%s, %s, %s). Will be overridden', src, tgt, name);
}
self.dgraph.setEdge(src, tgt, { vorigin, worigin, label }, name);
// Determine which follow function to use
let followFunc;
if (follow) {
if (!self.options.validateBeforeRouteCondition) {
followFunc = follow;
} else if (name === 'next') {
// Retain the original function name of route condition
followFunc = {
[follow.name]: (r, c) => (defaultNextFollow(r, c) && follow(r, c)),
}[follow.name];
} else {
// Retain the original function name of route condition
followFunc = {
[follow.name]: (r, c) => (defaultPrevFollow(r, c) && follow(r, c)),
}[follow.name];
}
} else if (name === 'next') {
followFunc = defaultNextFollow;
} else {
followFunc = defaultPrevFollow;
}
self.follows[name][`${src}/${tgt}`] = followFunc;
return this;
}
/**
* This is a convenience method for traversing all "next" routes, and returning
* the IDs of all waypoints visited along the way.
*
* @param {JourneyContext} context Journey Context.
* @param {object} options Options.
* @returns {Array<string>} List of traversed waypoints.
*/
traverse(context, options = {}) {
return this.traverseNextRoutes(context, options).map((e) => e.source);
}
traverseNextRoutes(context, options = {}) {
return this.traverseRoutes(context, { ...options, routeName: 'next' })
}
traversePrevRoutes(context, options = {}) {
return this.traverseRoutes(context, { ...options, routeName: 'prev' })
}
/**
* Traverse through the plan from a particular starting waypoint (usually an
* origin waypoint, but not necessarily). This is a non-exhaustive Graph
* Exploration.
*
* The last route in the list will contain the source of the last waypoint that
* can be reached, i.e. The waypoint that has no further satisfiable out-edges.
*
* If a cyclical set of routes are encountered, traversal will stop after
* reaching the first repeated waypoint.
*
* Options:
* string startWaypoint = Waypoint from which to start traversal
* string routeName = Follow routes matching this name (next | prev)
* Map history = Used to detect loops in traversal (internal use)
* function stopCondition = Condition that, if true, will stop traversal (useful for performance)
*
* @param {JourneyContext} context Journey context
* @param {object} options Options
* @returns {Array<object>} Routes that were traversed
* @throws {TypeError} When context is not a JourneyContext
*/
traverseRoutes(context, options = {}) {
if (!(context instanceof JourneyContext)) {
throw new TypeError(`Expected context to be an instance of JourneyContext, got ${typeof context}`);
}
const self = priv.get(this);
const {
startWaypoint = (this.getOrigins()[0] || {}).waypoint,
stopCondition = () => (false),
routeName,
} = options;
if (!self.dgraph.hasNode(startWaypoint)) {
throw new ReferenceError(`Plan does not contain waypoint '${startWaypoint}'`);
}
if (routeName === undefined) {
throw new ReferenceError('Route name must be provided');
}
const history = new Map();
const traverse = (startWP) => {
const target = self.dgraph.outEdges(startWP).filter((e) => {
if (e.name !== routeName) {
return false;
}
const route = makeRouteObject(self.dgraph, e);
try {
return self.follows[routeName][`${e.v}/${e.w}`](route, context);
} catch (ex) {
logger.warn('Route follow function threw an exception, "%s" (%s)', ex.message, `${e.v}/${e.w}`);
return false;
}
});
if (target.length === 1) {
const route = makeRouteObject(self.dgraph, target[0]);
const routeHash = `${route.name}/${route.source}/${route.target}`;
if (stopCondition(route)) {
return [route];
}
if (!history.has(routeHash)) {
history.set(routeHash, null);
const traversed = traverse(target[0].w);
const totalTrav = traversed.length;
const results = new Array(totalTrav + 1);
results[0] = route;
for (let i = 0; i < totalTrav; i++) {
results[i + 1] = traversed[i];
}
return results;
}
logger.debug('Encountered loop (%s). Stopping traversal.', `${route.source} -> ${route.target}`);
}
if (target.length > 1) {
const satisifed = target.map((t) => `${t.v} -> ${t.w}`);
logger.warn(
`Multiple routes were satisfied for "${routeName}" route (${satisifed.join(' / ')}). `
+ `Cannot determine which to use so stopping traversal at "${startWP}".`,
)
}
return [makeRouteObject(self.dgraph, {
v: startWP,
w: null,
name: routeName,
label: {
vorigin: undefined,
worigin: undefined,
},
})];
};
return traverse(startWaypoint);
}
/**
* Get raw graph data structure. This can be used with other libraries to
* generate graph visualisations, for example.
*
* @returns {graphlib.Graph} Graph data structure.
*/
getGraphStructure() {
return priv.get(this).dgraph;
}
}
module.exports = Plan;