Merge pull request #3133 from rhalff/addon-actions

Addon actions: fix slow logging
This commit is contained in:
Filipp Riabchun 2018-04-17 01:09:56 +03:00 committed by GitHub
commit 503c186c72
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 279 additions and 45 deletions

View File

@ -35,10 +35,12 @@ Import the `action` function and use it to create actions handlers. When creatin
```js
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { action, configureActions } from '@storybook/addon-actions';
import Button from './button';
action('button-click')
storiesOf('Button', module)
.add('default view', () => (
<Button onClick={ action('button-click') }>
@ -69,3 +71,34 @@ storiesOf('Button', module)
</Button>
))
```
## Configuration
Arguments which are passed to the action call will have to be serialized while be "transfered"
over the channel.
This is not very optimal and can cause lag when large objects are being logged, for this reason it is possible
to configure a maximum depth.
To apply the configuration globally use the `configureActions` function in your `config.js` file.
```js
import { configureActions } from '@storybook/addon-actions';
configureActions({
depth: 100
})
```
To apply the configuration per action use:
```js
action('my-action', {
depth: 5
})
```
### Available Options
|Name|Type|Description|Default|
|---|---|---|---|
|`depth`|Number|Configures the transfered depth of any logged objects.|`10`|

View File

@ -1,6 +1,8 @@
import { action, configureActions, decorateAction } from './preview';
// addons, panels and events get unique names using a prefix
export const ADDON_ID = 'storybook/actions';
export const PANEL_ID = `${ADDON_ID}/actions-panel`;
export const EVENT_ID = `${ADDON_ID}/action-event`;
export { action, decorateAction } from './preview';
export { action, configureActions, decorateAction };

View File

@ -1,25 +1,25 @@
import { DecycleError } from './errors';
import { getPropertiesList, typeReplacer } from './util';
import { getPropertiesList, typeReplacer, omitProperty } from './util';
import { CYCLIC_KEY } from './';
import { objectType } from './types';
import { DEPTH_KEY } from './types/object/configureDepth';
const { hasOwnProperty } = Object.prototype;
export default function decycle(object, depth = 10) {
const objects = new WeakMap();
let isCyclic = false;
const res = (function derez(value, path, _depth) {
const res = (function derez(value, path, _depth, _branchDepthMax) {
let oldPath;
let obj;
if (Object(value) === value && _depth > depth) {
const name = value.constructor ? value.constructor.name : typeof value;
return `[${name}...]`;
}
let maxDepth = _branchDepthMax;
const result = typeReplacer(value);
@ -51,19 +51,40 @@ export default function decycle(object, depth = 10) {
if (Array.isArray(value)) {
obj = [];
for (let i = 0; i < value.length; i += 1) {
obj[i] = derez(value[i], `${path}[${i}]`, _depth + 1);
obj[i] = derez(value[i], `${path}[${i}]`, _depth + 1, maxDepth);
}
} else {
obj = objectType.serialize(value);
getPropertiesList(value).forEach(name => {
try {
obj[name] = derez(value[name], `${path}[${JSON.stringify(name)}]`, _depth + 1);
} catch (error) {
console.error(error); // eslint-disable-line no-console
obj[name] = new DecycleError(error.message);
let newDepth;
if (hasOwnProperty.call(obj, DEPTH_KEY)) {
if (_depth + 1 < maxDepth) {
const depthKey = obj[DEPTH_KEY];
newDepth = depthKey === 0 ? 0 : _depth + depthKey;
maxDepth = newDepth >= depth ? depth : newDepth;
}
});
delete obj[DEPTH_KEY];
}
if (_depth <= maxDepth) {
getPropertiesList(value).forEach(name => {
if (!omitProperty(name)) {
try {
obj[name] = derez(
value[name],
`${path}[${JSON.stringify(name)}]`,
_depth + 1,
maxDepth
);
} catch (error) {
console.error(error); // eslint-disable-line no-console
obj[name] = new DecycleError(error.message);
}
}
});
}
}
if (_depth === 0 && value instanceof Object && isCyclic) {
@ -74,7 +95,7 @@ export default function decycle(object, depth = 10) {
}
return value;
})(object, '$', 0);
})(object, '$', 0, depth);
return res;
}

View File

@ -1,11 +1,15 @@
import objectType from '../';
import { DEPTH_KEY } from '../configureDepth';
describe('Object', () => {
it('Serializes Object', () => {
function C() {}
const c = new C();
expect(objectType.serialize(c)).toEqual({ [objectType.KEY]: 'C' });
expect(objectType.serialize(c)).toEqual({
[DEPTH_KEY]: 2,
[objectType.KEY]: 'C',
});
});
it('Deserializes Object', () => {

View File

@ -0,0 +1,7 @@
export const DEPTH_KEY = '$___storybook.depthKey';
export default function configureDepth(obj, depth = 0) {
obj[DEPTH_KEY] = depth; // eslint-disable-line no-param-reassign
return obj;
}

View File

@ -1,12 +1,21 @@
import createNamedObject from './createNamedObject';
import getObjectName from './getObjectName';
import configureDepth from './configureDepth';
const maxDepth = 2;
const KEY = '$___storybook.objectName';
const objectType = {
KEY,
// is: (value) => , // not used
serialize: value => ({ [KEY]: getObjectName(value) }),
serialize: value => {
const objectName = getObjectName(value);
if (objectName === 'Object') {
return { [KEY]: objectName };
}
return configureDepth({ [KEY]: objectName }, maxDepth);
},
deserialize: value => createNamedObject(value, KEY),
};

View File

@ -5,3 +5,4 @@ export muteProperty from './muteProperty';
export prepareArguments from './prepareArguments';
export typeReviver from './typeReviver';
export typeReplacer from './typeReplacer';
export omitProperty from './omitProperty';

View File

@ -0,0 +1,3 @@
export default function omitProperty(name) {
return name.startsWith('__') || name.startsWith('STORYBOOK_');
}

View File

@ -1,12 +1,8 @@
import { decycle } from '../index';
export default function prepareArguments(arg) {
if (arg && typeof arg.preventDefault !== 'undefined') {
return JSON.stringify(`[${arg.constructor.name}]`);
}
export default function prepareArguments(arg, depth) {
try {
return JSON.stringify(decycle(arg));
return JSON.stringify(decycle(arg, depth));
} catch (error) {
return error.toString(); // IE still cyclic.
}

View File

@ -0,0 +1,90 @@
import addons from '@storybook/addons';
import { action, configureActions } from '../../';
jest.mock('@storybook/addons');
const getChannelData = channel =>
channel.emit.mock.calls[channel.emit.mock.calls.length - 1][1].data;
describe('Action', () => {
const channel = { emit: jest.fn() };
addons.getChannel.mockReturnValue(channel);
it('with one argument', () => {
action('test-action')('one');
expect(getChannelData(channel).args[0]).toEqual('"one"');
});
it('with multiple arguments', () => {
action('test-action')('one', 'two', 'three');
expect(getChannelData(channel).args).toEqual(['"one"', '"two"', '"three"']);
});
it('with global depth configuration', () => {
const depth = 1;
configureActions({
depth,
});
action('test-action')({
root: {
one: {
two: 'foo',
},
},
});
expect(getChannelData(channel).args[0]).toEqual(
JSON.stringify({
'$___storybook.objectName': 'Object',
root: {
'$___storybook.objectName': 'Object',
one: {
'$___storybook.objectName': 'Object',
},
},
})
);
});
it('per action depth option overrides global config', () => {
configureActions({
depth: 1,
});
action('test-action', { depth: 3 })({
root: {
one: {
two: {
three: {
four: {
five: 'foo',
},
},
},
},
},
});
expect(getChannelData(channel).args[0]).toEqual(
JSON.stringify({
'$___storybook.objectName': 'Object',
root: {
'$___storybook.objectName': 'Object',
one: {
'$___storybook.objectName': 'Object',
two: {
'$___storybook.objectName': 'Object',
three: {
'$___storybook.objectName': 'Object',
},
},
},
},
})
);
});
});

View File

@ -0,0 +1,16 @@
import { config } from '../configureActions';
import { configureActions } from '../../';
describe('Configure Actions', () => {
it('can configure actions', () => {
const depth = 100;
configureActions({
depth,
});
expect(config).toEqual({
depth,
});
});
});

View File

@ -1,7 +1,7 @@
import addons from '@storybook/addons';
import uuid from 'uuid/v1';
import { action } from './preview';
import { undefinedType, symbolType } from './lib/types';
import { action } from '../';
import { undefinedType, symbolType } from '../../lib/types';
jest.mock('uuid/v1');
jest.mock('@storybook/addons');

View File

@ -1,12 +1,18 @@
import addons from '@storybook/addons';
import uuid from 'uuid/v1';
import { EVENT_ID } from './';
import { canConfigureName, prepareArguments } from './lib/util';
import addons from '@storybook/addons';
import { EVENT_ID } from '../';
import { canConfigureName, prepareArguments } from '../lib/util';
import { config } from './configureActions';
export default function action(name, options = {}) {
const actionOptions = {
...config,
...options,
};
export function action(name) {
// eslint-disable-next-line no-shadow
const handler = function action(..._args) {
const args = _args.map(prepareArguments);
const args = _args.map(arg => prepareArguments(arg, actionOptions.depth));
const channel = addons.getChannel();
const id = uuid();
channel.emit(EVENT_ID, {
@ -20,13 +26,3 @@ export function action(name) {
}
return handler;
}
export function decorateAction(decorators) {
return name => {
const callAction = action(name);
return (..._args) => {
const decorated = decorators.reduce((args, fn) => fn(args), _args);
callAction(...decorated);
};
};
}

View File

@ -0,0 +1,7 @@
export const config = {
depth: 10,
};
export function configureActions(options = {}) {
Object.assign(config, options);
}

View File

@ -0,0 +1,11 @@
import { action } from '../preview';
export default function decorateAction(decorators) {
return (name, options) => {
const callAction = action(name, options);
return (..._args) => {
const decorated = decorators.reduce((args, fn) => fn(args), _args);
callAction(...decorated);
};
};
}

View File

@ -0,0 +1,3 @@
export { default as action } from './action';
export { configureActions } from './configureActions';
export { default as decorateAction } from './decorateAction';

View File

@ -67,6 +67,11 @@ exports[`Storyshots Addons|Actions All types 1`] = `
>
Plain Object
</button>
<button
class="css-1yjiefr"
>
Object (depth: 2)
</button>
<button
class="css-1yjiefr"
>
@ -139,3 +144,11 @@ exports[`Storyshots Addons|Actions Reserved keyword as name 1`] = `
Delete
</button>
`;
exports[`Storyshots Addons|Actions configureActions 1`] = `
<button
class="css-1yjiefr"
>
Object (configured depth: 2)
</button>
`;

View File

@ -1,7 +1,7 @@
/* global window */
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action, decorateAction } from '@storybook/addon-actions';
import { action, configureActions, decorateAction } from '@storybook/addon-actions';
import { setOptions } from '@storybook/addon-options';
import { Button } from '@storybook/react/demo';
import { File } from 'global';
@ -66,7 +66,16 @@ storiesOf('Addons|Actions', module)
>
Multiple
</Button>
<Button onClick={() => action('Plain Object')({ foo: 'bar' })}>Plain Object</Button>
<Button onClick={() => action('Plain Object')({ foo: { bar: { baz: { bar: 'foo' } } } })}>
Plain Object
</Button>
<Button
onClick={() =>
action('ObjectDepth2', { depth: 2 })({ root: { one: { two: { three: 'foo' } } } })
}
>
Object (depth: 2)
</Button>
<Button onClick={() => action('RegExp')(reg)}>RegExp</Button>
<Button onClick={() => action('String')('foo')}>String</Button>
<Button onClick={() => action('Symbol')(Symbol('A_SYMBOL'))}>Symbol</Button>
@ -75,4 +84,17 @@ storiesOf('Addons|Actions', module)
<Button onClick={() => action('window')(window)}>Window</Button>
</div>
);
})
.add('configureActions', () => {
configureActions({
depth: 2,
});
return (
<Button
onClick={() => action('ConfiguredDepth')({ root: { one: { two: { three: 'foo' } } } })}
>
Object (configured depth: 2)
</Button>
);
});