Merge pull request #1993 from weaveworks/dance-tuning

Add anti-dance heuristics (and feature flags)
This commit is contained in:
David
2016-11-24 17:40:14 +01:00
committed by GitHub
8 changed files with 515 additions and 44 deletions

View File

@@ -35,6 +35,36 @@ describe('NodesLayout', () => {
'n2-n4': {id: 'n2-n4', source: 'n2', target: 'n4'}
})
},
rank4: {
nodes: fromJS({
n1: {id: 'n1', rank: 'A'},
n2: {id: 'n2', rank: 'A'},
n3: {id: 'n3', rank: 'B'},
n4: {id: 'n4', rank: 'B'}
}),
edges: fromJS({
'n1-n3': {id: 'n1-n3', source: 'n1', target: 'n3'},
'n1-n4': {id: 'n1-n4', source: 'n1', target: 'n4'},
'n2-n4': {id: 'n2-n4', source: 'n2', target: 'n4'}
})
},
rank6: {
nodes: fromJS({
n1: {id: 'n1', rank: 'A'},
n2: {id: 'n2', rank: 'A'},
n3: {id: 'n3', rank: 'B'},
n4: {id: 'n4', rank: 'B'},
n5: {id: 'n5', rank: 'A'},
n6: {id: 'n6', rank: 'B'},
}),
edges: fromJS({
'n1-n3': {id: 'n1-n3', source: 'n1', target: 'n3'},
'n1-n4': {id: 'n1-n4', source: 'n1', target: 'n4'},
'n1-n5': {id: 'n1-n5', source: 'n1', target: 'n5'},
'n2-n4': {id: 'n2-n4', source: 'n2', target: 'n4'},
'n2-n6': {id: 'n2-n6', source: 'n2', target: 'n6'},
})
},
removeEdge24: {
nodes: fromJS({
n1: {id: 'n1'},
@@ -86,10 +116,26 @@ describe('NodesLayout', () => {
edges: fromJS({
'n1-n4': {id: 'n1-n4', source: 'n1', target: 'n4'}
})
},
singlePortrait6: {
nodes: fromJS({
n1: {id: 'n1'},
n2: {id: 'n2'},
n3: {id: 'n3'},
n4: {id: 'n4'},
n5: {id: 'n5'},
n6: {id: 'n6'}
}),
edges: fromJS({
'n1-n4': {id: 'n1-n4', source: 'n1', target: 'n4'}
})
}
};
beforeEach(() => {
// clear feature flags
window.localStorage.clear();
options = {
nodeCache: makeMap(),
edgeCache: makeMap()
@@ -121,6 +167,54 @@ describe('NodesLayout', () => {
expect(hasUnseen).toBeTruthy();
});
it('shifts layouts to center', () => {
let xMin;
let xMax;
let yMin;
let yMax;
let xCenter;
let yCenter;
// make sure initial layout is centered
const original = NodesLayout.doLayout(
nodeSets.initial4.nodes,
nodeSets.initial4.edges
);
xMin = original.nodes.minBy(n => n.get('x'));
xMax = original.nodes.maxBy(n => n.get('x'));
yMin = original.nodes.minBy(n => n.get('y'));
yMax = original.nodes.maxBy(n => n.get('y'));
xCenter = (xMin.get('x') + xMax.get('x')) / 2;
yCenter = (yMin.get('y') + yMax.get('y')) / 2;
expect(xCenter).toEqual(NodesLayout.DEFAULT_WIDTH / 2);
expect(yCenter).toEqual(NodesLayout.DEFAULT_HEIGHT / 2);
// make sure re-running is idempotent
const rerun = NodesLayout.shiftLayoutToCenter(original);
xMin = rerun.nodes.minBy(n => n.get('x'));
xMax = rerun.nodes.maxBy(n => n.get('x'));
yMin = rerun.nodes.minBy(n => n.get('y'));
yMax = rerun.nodes.maxBy(n => n.get('y'));
xCenter = (xMin.get('x') + xMax.get('x')) / 2;
yCenter = (yMin.get('y') + yMax.get('y')) / 2;
expect(xCenter).toEqual(NodesLayout.DEFAULT_WIDTH / 2);
expect(yCenter).toEqual(NodesLayout.DEFAULT_HEIGHT / 2);
// shift after window was resized
const shifted = NodesLayout.shiftLayoutToCenter(original, {
width: 128,
height: 256
});
xMin = shifted.nodes.minBy(n => n.get('x'));
xMax = shifted.nodes.maxBy(n => n.get('x'));
yMin = shifted.nodes.minBy(n => n.get('y'));
yMax = shifted.nodes.maxBy(n => n.get('y'));
xCenter = (xMin.get('x') + xMax.get('x')) / 2;
yCenter = (yMin.get('y') + yMax.get('y')) / 2;
expect(xCenter).toEqual(128 / 2);
expect(yCenter).toEqual(256 / 2);
});
it('lays out initial nodeset in a rectangle', () => {
const result = NodesLayout.doLayout(
nodeSets.initial4.nodes,
@@ -266,7 +360,9 @@ describe('NodesLayout', () => {
it('renders single nodes next to portrait graph', () => {
const result = NodesLayout.doLayout(
nodeSets.singlePortrait.nodes,
nodeSets.singlePortrait.edges);
nodeSets.singlePortrait.edges,
{ noCache: true }
);
nodes = result.nodes.toJS();
@@ -282,4 +378,79 @@ describe('NodesLayout', () => {
expect(nodes.n1.x).toBeLessThan(nodes.n5.x);
expect(nodes.n2.x).toEqual(nodes.n5.x);
});
it('renders an additional single node in single nodes group', () => {
let result = NodesLayout.doLayout(
nodeSets.singlePortrait.nodes,
nodeSets.singlePortrait.edges,
{ noCache: true }
);
nodes = result.nodes.toJS();
// first square row on same level as top-most other node
expect(nodes.n1.y).toEqual(nodes.n2.y);
expect(nodes.n1.y).toEqual(nodes.n3.y);
expect(nodes.n4.y).toEqual(nodes.n5.y);
// all singles right to other nodes
expect(nodes.n1.x).toEqual(nodes.n4.x);
expect(nodes.n1.x).toBeLessThan(nodes.n2.x);
expect(nodes.n1.x).toBeLessThan(nodes.n3.x);
expect(nodes.n1.x).toBeLessThan(nodes.n5.x);
expect(nodes.n2.x).toEqual(nodes.n5.x);
options.cachedLayout = result;
options.nodeCache = options.nodeCache.merge(result.nodes);
options.edgeCache = options.edgeCache.merge(result.edge);
result = NodesLayout.doLayout(
nodeSets.singlePortrait6.nodes,
nodeSets.singlePortrait6.edges,
options
);
nodes = result.nodes.toJS();
expect(nodes.n1.x).toBeLessThan(nodes.n2.x);
expect(nodes.n1.x).toBeLessThan(nodes.n3.x);
expect(nodes.n1.x).toBeLessThan(nodes.n5.x);
expect(nodes.n1.x).toBeLessThan(nodes.n6.x);
});
it('adds a new node to existing layout in a line', () => {
// feature flag
window.localStorage.setItem('scope-experimental:layout-dance', true);
let result = NodesLayout.doLayout(
nodeSets.rank4.nodes,
nodeSets.rank4.edges,
{ noCache: true }
);
nodes = result.nodes.toJS();
coords = getNodeCoordinates(result.nodes);
options.cachedLayout = result;
options.nodeCache = options.nodeCache.merge(result.nodes);
options.edgeCache = options.edgeCache.merge(result.edge);
expect(NodesLayout.hasNewNodesOfExistingRank(
nodeSets.rank6.nodes,
nodeSets.rank6.edges,
result.nodes)).toBeTruthy();
result = NodesLayout.doLayout(
nodeSets.rank6.nodes,
nodeSets.rank6.edges,
options
);
nodes = result.nodes.toJS();
expect(nodes.n5.x).toBeGreaterThan(nodes.n1.x);
expect(nodes.n5.y).toEqual(nodes.n1.y);
expect(nodes.n6.x).toBeGreaterThan(nodes.n3.x);
expect(nodes.n6.y).toEqual(nodes.n3.y);
});
});

View File

@@ -3,13 +3,15 @@ import debug from 'debug';
import { fromJS, Map as makeMap, Set as ImmSet } from 'immutable';
import { EDGE_ID_SEPARATOR } from '../constants/naming';
import { featureIsEnabledAny } from '../utils/feature-utils';
import { buildTopologyCacheId, updateNodeDegrees } from '../utils/topology-utils';
const log = debug('scope:nodes-layout');
const topologyCaches = {};
const DEFAULT_WIDTH = 800;
const DEFAULT_MARGINS = {top: 0, left: 0};
export const DEFAULT_WIDTH = 800;
export const DEFAULT_HEIGHT = DEFAULT_WIDTH / 2;
export const DEFAULT_MARGINS = {top: 0, left: 0};
const DEFAULT_SCALE = val => val * 2;
const NODE_SIZE_FACTOR = 1;
const NODE_SEPARATION_FACTOR = 2.0;
@@ -118,6 +120,8 @@ function runLayoutEngine(graph, imNodes, imEdges, opts) {
// return object with the width and height of layout
return {
graphWidth: layout.width,
graphHeight: layout.height,
width: layout.width,
height: layout.height,
nodes,
@@ -125,6 +129,65 @@ function runLayoutEngine(graph, imNodes, imEdges, opts) {
};
}
/**
* Adds `points` array to edge based on location of source and target
* @param {Map} edge new edge
* @param {Map} nodeCache all nodes
* @returns {Map} modified edge
*/
function setSimpleEdgePoints(edge, nodeCache) {
const source = nodeCache.get(edge.get('source'));
const target = nodeCache.get(edge.get('target'));
return edge.set('points', fromJS([
{x: source.get('x'), y: source.get('y')},
{x: target.get('x'), y: target.get('y')}
]));
}
/**
* Layout nodes that have rank that already exists.
* Relies on only nodes being added that have a connection to an existing node
* while having a rank of an existing node. They will be laid out in the same
* line as the latter, with a direct connection between the existing and the new node.
* @param {object} layout Layout with nodes and edges
* @param {Map} nodeCache previous nodes
* @param {object} opts Options
* @return {object} new layout object
*/
export function doLayoutNewNodesOfExistingRank(layout, nodeCache, opts) {
const result = Object.assign({}, layout);
const options = opts || {};
const scale = options.scale || DEFAULT_SCALE;
const nodesep = scale(NODE_SEPARATION_FACTOR);
const nodeWidth = scale(NODE_SIZE_FACTOR);
// determine new nodes
const oldNodes = ImmSet.fromKeys(nodeCache);
const newNodes = ImmSet.fromKeys(layout.nodes.filter(n => n.get('degree') > 0))
.subtract(oldNodes);
result.nodes = layout.nodes.map(n => {
if (newNodes.contains(n.get('id'))) {
const nodesSameRank = nodeCache.filter(nn => nn.get('rank') === n.get('rank'));
if (nodesSameRank.size > 0) {
const y = nodesSameRank.first().get('y');
const x = nodesSameRank.maxBy(nn => nn.get('x')).get('x') + nodesep + nodeWidth;
return n.merge({ x, y });
}
return n;
}
return n;
});
result.edges = layout.edges.map(edge => {
if (!edge.has('points')) {
return setSimpleEdgePoints(edge, layout.nodes);
}
return edge;
});
return result;
}
/**
* Add coordinates to 0-degree nodes using a square layout
* Depending on the previous layout run's graph aspect ratio, the square will be
@@ -142,7 +205,9 @@ function layoutSingleNodes(layout, opts) {
const nodesep = scale(NODE_SEPARATION_FACTOR);
const nodeWidth = scale(NODE_SIZE_FACTOR);
const nodeHeight = scale(NODE_SIZE_FACTOR);
const aspectRatio = layout.height ? layout.width / layout.height : 1;
const graphHeight = layout.graphHeight || layout.height;
const graphWidth = layout.graphWidth || layout.width;
const aspectRatio = graphHeight ? graphWidth / graphHeight : 1;
let nodes = layout.nodes;
@@ -212,53 +277,44 @@ function layoutSingleNodes(layout, opts) {
* @param {Object} opts Options with width and margins
* @return {Object} modified layout
*/
function shiftLayoutToCenter(layout, opts) {
export function shiftLayoutToCenter(layout, opts) {
const result = Object.assign({}, layout);
const options = opts || {};
const margins = options.margins || DEFAULT_MARGINS;
const width = options.width || DEFAULT_WIDTH;
const height = options.height || width / 2;
const height = options.height || DEFAULT_HEIGHT;
let offsetX = 0 + margins.left;
let offsetY = 0 + margins.top;
if (layout.width < width) {
offsetX = (width - layout.width) / 2 + margins.left;
const xMin = layout.nodes.minBy(n => n.get('x'));
const xMax = layout.nodes.maxBy(n => n.get('x'));
offsetX = (width - (xMin.get('x') + xMax.get('x'))) / 2 + margins.left;
}
if (layout.height < height) {
offsetY = (height - layout.height) / 2 + margins.top;
const yMin = layout.nodes.minBy(n => n.get('y'));
const yMax = layout.nodes.maxBy(n => n.get('y'));
offsetY = (height - (yMin.get('y') + yMax.get('y'))) / 2 + margins.top;
}
result.nodes = layout.nodes.map(node => node.merge({
x: node.get('x') + offsetX,
y: node.get('y') + offsetY
}));
if (offsetX || offsetY) {
result.nodes = layout.nodes.map(node => node.merge({
x: node.get('x') + offsetX,
y: node.get('y') + offsetY
}));
result.edges = layout.edges.map(edge => edge.update('points',
points => points.map(point => point.merge({
x: point.get('x') + offsetX,
y: point.get('y') + offsetY
}))
));
result.edges = layout.edges.map(edge => edge.update('points',
points => points.map(point => point.merge({
x: point.get('x') + offsetX,
y: point.get('y') + offsetY
}))
));
}
return result;
}
/**
* Adds `points` array to edge based on location of source and target
* @param {Map} edge new edge
* @param {Map} nodeCache all nodes
* @returns {Map} modified edge
*/
function setSimpleEdgePoints(edge, nodeCache) {
const source = nodeCache.get(edge.get('source'));
const target = nodeCache.get(edge.get('target'));
return edge.set('points', fromJS([
{x: source.get('x'), y: source.get('y')},
{x: target.get('x'), y: target.get('y')}
]));
}
/**
* Determine if nodes were added between node sets
* @param {Map} nodes new Map of nodes
@@ -274,6 +330,45 @@ export function hasUnseenNodes(nodes, cache) {
return hasUnseen;
}
/**
* Determine if all new nodes are 0-degree nodes
* Requires cached nodes (implies a previous layout run).
* @param {Map} nodes new Map of nodes
* @param {Map} cache old Map of nodes
* @return {Boolean} True if all new nodes are 0-nodes
*/
function hasNewSingleNode(nodes, cache) {
const oldNodes = ImmSet.fromKeys(cache);
const newNodes = ImmSet.fromKeys(nodes).subtract(oldNodes);
const hasNewSingleNodes = newNodes.every(key => nodes.getIn([key, 'degree']) === 0);
return oldNodes.size > 0 && hasNewSingleNodes;
}
/**
* Determine if all new nodes are of existing ranks
* Requires cached nodes (implies a previous layout run).
* @param {Map} nodes new Map of nodes
* @param {Map} edges new Map of edges
* @param {Map} cache old Map of nodes
* @return {Boolean} True if all new nodes have a rank that already exists
*/
export function hasNewNodesOfExistingRank(nodes, edges, cache) {
const oldNodes = ImmSet.fromKeys(cache);
const newNodes = ImmSet.fromKeys(nodes).subtract(oldNodes);
// if new there are edges that connect 2 new nodes, need a full layout
const bothNodesNew = edges.find(edge => newNodes.contains(edge.get('source'))
&& newNodes.contains(edge.get('target')));
if (bothNodesNew) {
return false;
}
const oldRanks = cache.filter(n => n.get('rank')).map(n => n.get('rank')).toSet();
const hasNewNodesOfExistingRankOrSingle = newNodes.every(key => nodes.getIn([key, 'degree']) === 0
|| oldRanks.contains(nodes.getIn([key, 'rank'])));
return oldNodes.size > 0 && hasNewNodesOfExistingRankOrSingle;
}
/**
* Determine if edge has same endpoints in new nodes as well as in the nodeCache
* @param {Map} edge Edge with source and target
@@ -315,13 +410,16 @@ function cloneLayout(layout, nodes, edges) {
*/
function copyLayoutProperties(layout, nodeCache, edgeCache) {
const result = Object.assign({}, layout);
result.nodes = layout.nodes.map(node => node.merge(nodeCache.get(node.get('id'))));
result.nodes = layout.nodes.map(node => (nodeCache.has(node.get('id'))
? node.merge(nodeCache.get(node.get('id'))) : node));
result.edges = layout.edges.map(edge => {
if (edgeCache.has(edge.get('id'))
&& hasSameEndpoints(edgeCache.get(edge.get('id')), result.nodes)) {
return edge.merge(edgeCache.get(edge.get('id')));
} else if (nodeCache.get(edge.get('source')) && nodeCache.get(edge.get('target'))) {
return setSimpleEdgePoints(edge, nodeCache);
}
return setSimpleEdgePoints(edge, nodeCache);
return edge;
});
return result;
}
@@ -340,7 +438,7 @@ export function doLayout(immNodes, immEdges, opts) {
const cacheId = buildTopologyCacheId(options.topologyId, options.topologyOptions);
// one engine and node and edge caches per topology, to keep renderings similar
if (!topologyCaches[cacheId]) {
if (options.noCache || !topologyCaches[cacheId]) {
topologyCaches[cacheId] = {
nodeCache: makeMap(),
edgeCache: makeMap(),
@@ -352,22 +450,42 @@ export function doLayout(immNodes, immEdges, opts) {
const cachedLayout = options.cachedLayout || cache.cachedLayout;
const nodeCache = options.nodeCache || cache.nodeCache;
const edgeCache = options.edgeCache || cache.edgeCache;
const useCache = !options.forceRelayout && cachedLayout && nodeCache && edgeCache;
let layout;
++layoutRuns;
if (!options.forceRelayout && cachedLayout && nodeCache && edgeCache
&& !hasUnseenNodes(immNodes, nodeCache)) {
if (useCache && !hasUnseenNodes(immNodes, nodeCache)) {
log('skip layout, trivial adjustment', ++layoutRunsTrivial, layoutRuns);
layout = cloneLayout(cachedLayout, immNodes, immEdges);
// copy old properties, works also if nodes get re-added
layout = copyLayoutProperties(layout, nodeCache, edgeCache);
} else {
const graph = cache.graph;
const nodesWithDegrees = updateNodeDegrees(immNodes, immEdges);
layout = runLayoutEngine(graph, nodesWithDegrees, immEdges, opts);
if (!layout) {
return layout;
if (useCache
&& featureIsEnabledAny('layout-dance', 'layout-dance-single')
&& hasNewSingleNode(nodesWithDegrees, nodeCache)) {
// special case: new nodes are 0-degree nodes, no need for layout run,
// they will be laid out further below
log('skip layout, only 0-degree node(s) added');
layout = cloneLayout(cachedLayout, nodesWithDegrees, immEdges);
layout = copyLayoutProperties(layout, nodeCache, edgeCache);
} else if (useCache
&& featureIsEnabledAny('layout-dance', 'layout-dance-rank')
&& hasNewNodesOfExistingRank(nodesWithDegrees, immEdges, nodeCache)) {
// special case: few new nodes were added, no need for layout run,
// they will inserted according to ranks
log('skip layout, used rank-based insertion');
layout = cloneLayout(cachedLayout, nodesWithDegrees, immEdges);
layout = copyLayoutProperties(layout, nodeCache, edgeCache);
layout = doLayoutNewNodesOfExistingRank(layout, nodeCache, opts);
} else {
const graph = cache.graph;
layout = runLayoutEngine(graph, nodesWithDegrees, immEdges, opts);
if (!layout) {
return layout;
}
}
layout = layoutSingleNodes(layout, opts);
layout = shiftLayoutToCenter(layout, opts);
}

View File

@@ -4,7 +4,7 @@ import d3 from 'd3';
import _ from 'lodash';
import Perf from 'react-addons-perf';
import { connect } from 'react-redux';
import { fromJS } from 'immutable';
import { fromJS, Set as makeSet } from 'immutable';
import debug from 'debug';
const log = debug('scope:debug-panel');
@@ -160,6 +160,10 @@ class DebugToolbar extends React.Component {
this.onChange = this.onChange.bind(this);
this.toggleColors = this.toggleColors.bind(this);
this.addNodes = this.addNodes.bind(this);
this.intermittentTimer = null;
this.intermittentNodes = makeSet();
this.shortLivedTimer = null;
this.shortLivedNodes = makeSet();
this.state = {
nodesToAdd: 30,
showColors: false
@@ -184,6 +188,53 @@ class DebugToolbar extends React.Component {
this.asyncDispatch(setAppState(state => state.set('topologiesLoaded', !loading)));
}
setIntermittent() {
// simulate epheremal nodes
if (this.intermittentTimer) {
clearInterval(this.intermittentTimer);
this.intermittentTimer = null;
} else {
this.intermittentTimer = setInterval(() => {
// add new node
this.addNodes(1);
// remove random node
const ns = this.props.nodes;
const nodeNames = ns.keySeq().toJS();
const randomNode = _.sample(nodeNames);
this.asyncDispatch(receiveNodesDelta({
remove: [randomNode]
}));
}, 1000);
}
}
setShortLived() {
// simulate nodes with same ID popping in and out
if (this.shortLivedTimer) {
clearInterval(this.shortLivedTimer);
this.shortLivedTimer = null;
} else {
this.shortLivedTimer = setInterval(() => {
// filter random node
const ns = this.props.nodes;
const nodeNames = ns.keySeq().toJS();
const randomNode = _.sample(nodeNames);
if (randomNode) {
let nextNodes = ns.setIn([randomNode, 'filtered'], true);
this.shortLivedNodes = this.shortLivedNodes.add(randomNode);
// bring nodes back after a bit
if (this.shortLivedNodes.size > 5) {
const returningNode = this.shortLivedNodes.first();
this.shortLivedNodes = this.shortLivedNodes.rest();
nextNodes = nextNodes.setIn([returningNode, 'filtered'], false);
}
this.asyncDispatch(setAppState(state => state.set('nodes', nextNodes)));
}
}, 1000);
}
}
updateAdjacencies() {
const ns = this.props.nodes;
const nodeNames = ns.keySeq().toJS();
@@ -303,6 +354,12 @@ class DebugToolbar extends React.Component {
<button onClick={() => this.setLoading(false)}>Stop</button>
</div>
<div>
<label>Short-lived nodes</label>
<button onClick={() => this.setShortLived()}>Toggle short-lived nodes</button>
<button onClick={() => this.setIntermittent()}>Toggle intermittent nodes</button>
</div>
<div>
<label>Measure React perf for </label>
<button onClick={() => startPerf(2)}>2s</button>

View File

@@ -0,0 +1,43 @@
const FU = require('../feature-utils');
describe('FeatureUtils', () => {
const FEATURE_X_KEY = 'my feature 1';
const FEATURE_Y_KEY = 'my feature 2';
beforeEach(() => {
FU.setFeature(FEATURE_X_KEY, false);
FU.setFeature(FEATURE_Y_KEY, false);
});
describe('Setting of features', () => {
it('should not have any features by default', () => {
expect(FU.featureIsEnabled(FEATURE_X_KEY)).toBeFalsy();
expect(FU.featureIsEnabled(FEATURE_Y_KEY)).toBeFalsy();
});
it('should work with enabling one feature', () => {
let success;
expect(FU.featureIsEnabled(FEATURE_X_KEY)).toBeFalsy();
success = FU.setFeature(FEATURE_X_KEY, true);
expect(success).toBeTruthy();
expect(FU.featureIsEnabled(FEATURE_X_KEY)).toBeTruthy();
expect(FU.featureIsEnabled(FEATURE_Y_KEY)).toBeFalsy();
success = FU.setFeature(FEATURE_X_KEY, false);
expect(success).toBeTruthy();
expect(FU.featureIsEnabled(FEATURE_X_KEY)).toBeFalsy();
});
it('should allow for either feature', () => {
let success;
expect(FU.featureIsEnabledAny(FEATURE_X_KEY, FEATURE_Y_KEY)).toBeFalsy();
success = FU.setFeature(FEATURE_X_KEY, true);
expect(success).toBeTruthy();
expect(FU.featureIsEnabledAny(FEATURE_X_KEY, FEATURE_Y_KEY)).toBeTruthy();
success = FU.setFeature(FEATURE_X_KEY, false);
success = FU.setFeature(FEATURE_Y_KEY, true);
expect(success).toBeTruthy();
expect(FU.featureIsEnabledAny(FEATURE_X_KEY, FEATURE_Y_KEY)).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,39 @@
import { storageGet, storageSet } from './storage-utils';
// prefix for all feature flags
const STORAGE_KEY_PREFIX = 'scope-experimental:';
const getKey = key => `${STORAGE_KEY_PREFIX}${key}`;
/**
* Returns true if `feature` is enabled
*
* Features can be enabled either via calling `setFeature()` or by setting
* `localStorage.scope-experimental:featureName = true` in the console.
* @param {String} feature Feature name, ideally one word or hyphenated
* @return {Boolean} True if feature is enabled
*/
export function featureIsEnabled(feature) {
return storageGet(getKey(feature));
}
/**
* Returns true if any of the features given as arguments are enabled.
*
* Useful if features are hierarchical, e.g.:
* `featureIsEnabledAny('superFeature', 'subFeature')`
* @param {String} args Feature names
* @return {Boolean} True if any of the features are enabled
*/
export function featureIsEnabledAny(...args) {
return Array.prototype.some.call(args, feature => featureIsEnabled(feature));
}
/**
* Set true/false if a feature is enabled.
* @param {String} feature Feature name
* @param {Boolean} isEnabled true/false
*/
export function setFeature(feature, isEnabled) {
return storageSet(getKey(feature), isEnabled);
}

View File

@@ -6,7 +6,7 @@ const log = debug('scope:storage-utils');
const storage = typeof(Storage) !== 'undefined' ? window.localStorage : null;
export function storageGet(key, defaultValue) {
if (storage && storage[key] !== undefined) {
if (storage && storage.getItem(key) !== undefined) {
return storage.getItem(key);
}
return defaultValue;
@@ -16,8 +16,31 @@ export function storageSet(key, value) {
if (storage) {
try {
storage.setItem(key, value);
return true;
} catch (e) {
log('Error storing value in storage. Maybe full? Could not store key.', key);
}
}
return false;
}
export function storageGetObject(key, defaultValue) {
const value = storageGet(key);
if (value) {
try {
return JSON.parse(value);
} catch (e) {
log('Error getting object for key.', key);
}
}
return defaultValue;
}
export function storageSetObject(key, obj) {
try {
return storageSet(key, JSON.stringify(obj));
} catch (e) {
log('Error encoding object for key', key);
}
return false;
}

View File

@@ -90,6 +90,9 @@
},
"jest": {
"transform": {".*": "<rootDir>/node_modules/babel-jest"},
"setupFiles": [
"<rootDir>/test/support/localStorage.js"
],
"testPathDirs": [
"<rootDir>/app/scripts"
],

View File

@@ -0,0 +1,17 @@
const localStorageMock = (function() {
let store = {};
return {
store,
getItem: function(key) {
return store[key];
},
setItem: function(key, value) {
store[key] = value;
},
clear: function() {
store = {};
}
};
})();
Object.defineProperty(window, 'Storage', { value: localStorageMock });
Object.defineProperty(window, 'localStorage', { value: localStorageMock });