Merge pull request #12699 from storybookjs/1009-async-loaders

Core: Add async loaders
This commit is contained in:
Michael Shilman 2020-10-12 19:29:30 +08:00 committed by GitHub
commit dd08c4ba69
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 608 additions and 144 deletions

View File

@ -3,11 +3,11 @@ version: 2.1
executors:
sb_node:
parameters:
class:
description: The Resource class
type: enum
enum: ["small", "medium", "large", "xlarge"]
default: "medium"
class:
description: The Resource class
type: enum
enum: ['small', 'medium', 'large', 'xlarge']
default: 'medium'
working_directory: /tmp/storybook
docker:
- image: circleci/node:10-browsers
@ -360,7 +360,6 @@ jobs:
name: Upload coverage
command: yarn coverage
workflows:
test:
jobs:

View File

@ -2,6 +2,7 @@
- [From version 6.0.x to 6.1.0](#from-version-60x-to-610)
- [6.1 deprecations](#61-deprecations)
- [Deprecated storyFn](#deprecated-storyfn)
- [Deprecated onBeforeRender](#deprecated-onbeforerender)
- [Deprecated grid parameter](#deprecated-grid-parameter)
- [Deprecated package-composition disabled parameter](#deprecated-package-composition-disabled-parameter)
@ -120,8 +121,8 @@
- [Addon story parameters](#addon-story-parameters)
- [From version 3.3.x to 3.4.x](#from-version-33x-to-34x)
- [From version 3.2.x to 3.3.x](#from-version-32x-to-33x)
- [`babel-core` is now a peer dependency (#2494)](#babel-core-is-now-a-peer-dependency-2494)
- [Base webpack config now contains vital plugins (#1775)](#base-webpack-config-now-contains-vital-plugins-1775)
- [`babel-core` is now a peer dependency #2494](#babel-core-is-now-a-peer-dependency-2494)
- [Base webpack config now contains vital plugins #1775](#base-webpack-config-now-contains-vital-plugins-1775)
- [Refactored Knobs](#refactored-knobs)
- [From version 3.1.x to 3.2.x](#from-version-31x-to-32x)
- [Moved TypeScript addons definitions](#moved-typescript-addons-definitions)
@ -138,6 +139,27 @@
### 6.1 deprecations
#### Deprecated storyFn
Each item in the story store contains a field called `storyFn`, which is a fully decorated story that's applied to the denormalized story parameters. Starting in 6.0 we've stopped using this API internally, and have replaced it with a new field called `unboundStoryFn` which, unlike `storyFn`, must passed a story context, typically produced by `applyLoaders`;
Before:
```js
const { storyFn } = store.fromId('some--id');
console.log(storyFn());
```
After:
```js
const { unboundStoryFn, applyLoaders } = store.fromId('some--id');
const context = await applyLoaders();
console.log(unboundStoryFn(context));
```
If you're not using loaders, `storyFn` will work as before. If you are, you'll need to use the new approach.
#### Deprecated onBeforeRender
The `@storybook/addon-docs` previously accepted a `jsx` option called `onBeforeRender`, which was unfortunately named as it was called after the render.
@ -1717,7 +1739,7 @@ There are no expected breaking changes in the 3.4.x release, but 3.4 contains a
It wasn't expected that there would be any breaking changes in this release, but unfortunately it turned out that there are some. We're revisiting our [release strategy](https://github.com/storybookjs/storybook/blob/master/RELEASES.md) to follow semver more strictly.
Also read on if you're using `addon-knobs`: we advise an update to your code for efficiency's sake.
### `babel-core` is now a peer dependency ([#2494](https://github.com/storybookjs/storybook/pull/2494))
### `babel-core` is now a peer dependency #2494
This affects you if you don't use babel in your project. You may need to add `babel-core` as dev dependency:
@ -1727,7 +1749,7 @@ yarn add babel-core --dev
This was done to support different major versions of babel.
### Base webpack config now contains vital plugins ([#1775](https://github.com/storybookjs/storybook/pull/1775))
### Base webpack config now contains vital plugins #1775
This affects you if you use custom webpack config in [Full Control Mode](https://storybook.js.org/docs/react/configure/webpack#full-control-mode) while not preserving the plugins from `storybookBaseConfig`. Before `3.3`, preserving them was a recommendation, but now it [became](https://github.com/storybookjs/storybook/pull/2578) a requirement.

View File

@ -0,0 +1,10 @@
import { Button } from '@storybook/react/demo';
import { Story, Meta } from '@storybook/addon-docs/blocks';
<Meta title="Button" loaders={[async () => ({ foo: 1 })]} />
# Story with loader
<Story name="one" loaders={[async () => ({ bar: 2 })]}>
<Button>One</Button>
</Story>

View File

@ -0,0 +1,85 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`docs-mdx-compiler-plugin loaders.mdx 1`] = `
"/* @jsx mdx */
import { assertIsFn, AddContext } from '@storybook/addon-docs/blocks';
import { Button } from '@storybook/react/demo';
import { Story, Meta } from '@storybook/addon-docs/blocks';
const makeShortcode = (name) =>
function MDXDefaultShortcode(props) {
console.warn(
'Component ' +
name +
' was not imported, exported, or provided by MDXProvider as global scope'
);
return <div {...props} />;
};
const layoutProps = {};
const MDXLayout = 'wrapper';
function MDXContent({ components, ...props }) {
return (
<MDXLayout {...layoutProps} {...props} components={components} mdxType=\\"MDXLayout\\">
<Meta
title=\\"Button\\"
loaders={[
async () => ({
foo: 1,
}),
]}
mdxType=\\"Meta\\"
/>
<h1>{\`Story with loader\`}</h1>
<Story
name=\\"one\\"
loaders={[
async () => ({
bar: 2,
}),
]}
mdxType=\\"Story\\"
>
<Button mdxType=\\"Button\\">One</Button>
</Story>
</MDXLayout>
);
}
MDXContent.isMDXComponent = true;
export const one = () => <Button>One</Button>;
one.storyName = 'one';
one.parameters = { storySource: { source: '<Button>One</Button>' } };
one.loaders = [
async () => ({
bar: 2,
}),
];
const componentMeta = {
title: 'Button',
loaders: [
async () => ({
foo: 1,
}),
],
includeStories: ['one'],
};
const mdxStoryNameToKey = { one: 'one' };
componentMeta.parameters = componentMeta.parameters || {};
componentMeta.parameters.docs = {
...(componentMeta.parameters.docs || {}),
page: () => (
<AddContext mdxStoryNameToKey={mdxStoryNameToKey} mdxComponentMeta={componentMeta}>
<MDXContent />
</AddContext>
),
};
export default componentMeta;
"
`;

View File

@ -193,6 +193,13 @@ function genStoryExport(ast, context) {
statements.push(`${storyKey}.decorators = ${decos};`);
}
let loaders = getAttr(ast.openingElement, 'loaders');
loaders = loaders && loaders.expression;
if (loaders) {
const { code: loaderCode } = generate(loaders, {});
statements.push(`${storyKey}.loaders = ${loaderCode};`);
}
// eslint-disable-next-line no-param-reassign
context.storyNameToKey[storyName] = storyKey;
@ -242,6 +249,7 @@ function genMeta(ast, options) {
id = id && `'${id.value}'`;
const parameters = genAttribute('parameters', ast.openingElement);
const decorators = genAttribute('decorators', ast.openingElement);
const loaders = genAttribute('loaders', ast.openingElement);
const component = genAttribute('component', ast.openingElement);
const subcomponents = genAttribute('subcomponents', ast.openingElement);
const args = genAttribute('args', ast.openingElement);
@ -252,6 +260,7 @@ function genMeta(ast, options) {
id,
parameters,
decorators,
loaders,
component,
subcomponents,
args,

View File

@ -48,39 +48,37 @@ function testStorySnapshots(options: StoryshotsOptions = {}) {
stories2snapsConverter,
};
const data = storybook
.raw()
.reduce(
(acc, item) => {
if (storyNameRegex && !item.name.match(storyNameRegex)) {
return acc;
}
if (storyKindRegex && !item.kind.match(storyKindRegex)) {
return acc;
}
const { kind, storyFn: render, parameters } = item;
const existing = acc.find((i: any) => i.kind === kind);
const { fileName } = item.parameters;
if (!isDisabled(parameters.storyshots)) {
if (existing) {
existing.children.push({ ...item, render, fileName });
} else {
acc.push({
kind,
children: [{ ...item, render, fileName }],
});
}
}
const data = storybook.raw().reduce(
(acc, item) => {
if (storyNameRegex && !item.name.match(storyNameRegex)) {
return acc;
},
[] as {
kind: string;
children: any[];
}[]
);
}
if (storyKindRegex && !item.kind.match(storyKindRegex)) {
return acc;
}
const { kind, storyFn: render, parameters } = item;
const existing = acc.find((i: any) => i.kind === kind);
const { fileName } = item.parameters;
if (!isDisabled(parameters.storyshots)) {
if (existing) {
existing.children.push({ ...item, render, fileName });
} else {
acc.push({
kind,
children: [{ ...item, render, fileName }],
});
}
}
return acc;
},
[] as {
kind: string;
children: any[];
}[]
);
if (data.length) {
callTestMethodGlobals(testMethod);

View File

@ -14,7 +14,6 @@ export default function renderMain({
forceRender,
}: RenderContext) {
const element = storyFn();
showMain();
if (typeof element === 'string') {
rootElement.innerHTML = element;

View File

@ -4,10 +4,10 @@
export const CustomSource = () => Template.bind({});
CustomSource.parameters = {
docs: {
source: {
code: 'Some custom string here'
}
docs: {
source: {
code: 'Some custom string here',
},
},
};
```

View File

@ -0,0 +1,14 @@
```js
// TodoItem.stories.js
import React from 'react';
import fetch from 'node-fetch';
import { TodoItem } from './TodoItem';
export const Primary = (args, { loaded: { todo } }) => <TodoItem {...args} {...todo} />;
Primary.loaders = [
async () => ({
todo: (await fetch('https://jsonplaceholder.typicode.com/todos/1')).json(),
}),
];
```

View File

@ -0,0 +1,12 @@
```js
// .storybook/preview.js
import React from 'react';
import fetch from 'node-fetch';
export const loaders = [
async () => ({
currentUser: (await fetch('https://jsonplaceholder.typicode.com/users/1')).json(),
}),
];
```

View File

@ -73,6 +73,11 @@ module.exports = {
title: 'Decorators',
type: 'link',
},
{
pathSegment: 'loaders',
title: 'Loaders',
type: 'link',
},
{
pathSegment: 'naming-components-and-hierarchy',
title: 'Naming components and hierarchy',

View File

@ -0,0 +1,63 @@
---
title: 'Loaders'
---
Loaders are asynchronous functions that load data for a story and its [decorators](./decorators.md). A story's loaders run before the story renders, and the loaded data is passed into the story via its render context.
Loaders can be used to load any asset (e.g. lazy-loaded components), but they are are typically used to fetch remote API data to be used in a story.
> NOTE: [Args](./args.md) are the recommended way to manage story data, and we're building up an ecosystem of tools and techniques around them. Loaders are an advanced feature ("escape hatch") and we only recommend using them if you have a specific need that can't be fulfilled by other means.
## Fetching API data
Stories are isolated component examples that render internal data that's defined as part of the story or alongside the story as [args](./args.md).
Loaders are useful when you need to load story data externally, e.g. from a remote API. Consider the following example that fetches a todo item for display in a todo list:
<!-- prettier-ignore-start -->
<CodeSnippets
paths={[
'react/loader-story.js.mdx',
]}
/>
<!-- prettier-ignore-end -->
The loaded data is combined into a `loaded` field on the story context, which is the second argument to a story function. In this example we spread the story's args in first, so they take priority over the static data provided by the loader.
## Global loaders
We can also set a loader for **all stories** via the `loaders` export of your [`.storybook/preview.js`](../configure/overview.md#configure-story-rendering) file (this is the file where you configure all stories):
<!-- prettier-ignore-start -->
<CodeSnippets
paths={[
'react/storybook-preview-global-loader.js.mdx',
]}
/>
<!-- prettier-ignore-end -->
In this example, we load a "current user" that is available as `loaded.currentUser` for all stories.
## Loader inheritance
Like parameters, loaders can be defined globally, at the component level and for a single story (as weve seen).
All loaders, defined at all levels that apply to a story, run before the story is rendered.
- All loaders run in parallel
- All results are the `loaded` field in the story context
- If there are keys that overlap, "later" loaders take precedence (from lowest to highest):
- Global loaders, in the order they are defined
- Component loaders, in the order they are defined
- Story loaders, in the order they are defined
## Known limitations
Loaders have the following known limitations:
- They are not yet compatible with the storyshots addon ([#12703](https://github.com/storybookjs/storybook/issues/12703)).
- They are not yet compatible with inline-rendered stories in Storybook Docs ([#12726](https://github.com/storybookjs/storybook/issues/12726)).

View File

@ -0,0 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Storyshots Core/Loaders Story 1`] = `
<div>
Loaded Value is undefined
</div>
`;

View File

@ -0,0 +1,7 @@
export default {
title: 'Core/Loaders',
loaders: [async () => new Promise((r) => setTimeout(() => r({ kindValue: 7 }), 3000))],
};
export const Story = (args, { loaded }) =>
`<div>Loaded Value is ${JSON.stringify(loaded, null, 2)}</div>`;

View File

@ -195,3 +195,5 @@ export const globalTypes = {
},
},
};
export const loaders = [async () => ({ globalValue: 1 })];

View File

@ -0,0 +1,11 @@
import React from 'react';
export default {
title: 'Core/Loaders',
loaders: [async () => new Promise((r) => setTimeout(() => r({ kindValue: 7 }), 3000))],
};
export const Story = (args, { loaded }) => (
<div>Loaded Value is {JSON.stringify(loaded, null, 2)}</div>
);
Story.loaders = [async () => ({ storyValue: 3 })];

View File

@ -129,6 +129,7 @@ export interface StoryApi<StoryFnReturnType = unknown> {
parameters?: Parameters
) => StoryApi<StoryFnReturnType>;
addDecorator: (decorator: DecoratorFunction<StoryFnReturnType>) => StoryApi<StoryFnReturnType>;
addLoader: (decorator: LoaderFunction) => StoryApi<StoryFnReturnType>;
addParameters: (parameters: Parameters) => StoryApi<StoryFnReturnType>;
[k: string]: string | ClientApiReturnFn<StoryFnReturnType>;
}
@ -138,6 +139,8 @@ export type DecoratorFunction<StoryFnReturnType = unknown> = (
c: StoryContext
) => ReturnType<StoryFn<StoryFnReturnType>>;
export type LoaderFunction = (c: StoryContext) => Promise<Record<string, any>>;
export type DecorateStoryFunction<StoryFnReturnType = unknown> = (
storyFn: StoryFn<StoryFnReturnType>,
decorators: DecoratorFunction<StoryFnReturnType>[]

View File

@ -52,4 +52,4 @@
"@storybook/ui": "6.1.0-alpha.22",
"@storybook/vue": "6.1.0-alpha.22",
"@storybook/web-components": "6.1.0-alpha.22"
}
}

View File

@ -43,6 +43,7 @@
"qs": "^6.6.0",
"react": "^16.8.3",
"react-dom": "^16.8.3",
"regenerator-runtime": "^0.13.3",
"stable": "^0.1.8",
"store2": "^2.7.1",
"ts-dedent": "^1.1.1",

View File

@ -2,7 +2,7 @@
import deprecate from 'util-deprecate';
import dedent from 'ts-dedent';
import { logger } from '@storybook/client-logger';
import { StoryFn, Parameters, DecorateStoryFunction } from '@storybook/addons';
import { StoryFn, Parameters, LoaderFunction, DecorateStoryFunction } from '@storybook/addons';
import { toId } from '@storybook/csf';
import {
@ -24,7 +24,7 @@ const addDecoratorDeprecationWarning = deprecate(
() => {},
`\`addDecorator\` is deprecated, and will be removed in Storybook 7.0.
Instead, use \`export const decorators = [];\` in your \`preview.js\`.
Read more at https://github.com/storybookjs/storybook/MIGRATION.md#deprecated-addparameters-and-adddecorator).`
Read more at https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#deprecated-addparameters-and-adddecorator).`
);
export const addDecorator = (decorator: DecoratorFunction, deprecationWarning = true) => {
if (!singleton)
@ -39,7 +39,7 @@ const addParametersDeprecationWarning = deprecate(
() => {},
`\`addParameters\` is deprecated, and will be removed in Storybook 7.0.
Instead, use \`export const parameters = {};\` in your \`preview.js\`.
Read more at https://github.com/storybookjs/storybook/MIGRATION.md#deprecated-addparameters-and-adddecorator).`
Read more at https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#deprecated-addparameters-and-adddecorator).`
);
export const addParameters = (parameters: Parameters, deprecationWarning = true) => {
if (!singleton)
@ -50,6 +50,21 @@ export const addParameters = (parameters: Parameters, deprecationWarning = true)
singleton.addParameters(parameters);
};
const addLoaderDeprecationWarning = deprecate(
() => {},
`\`addLoader\` is deprecated, and will be removed in Storybook 7.0.
Instead, use \`export const loaders = [];\` in your \`preview.js\`.
Read more at https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#deprecated-addparameters-and-adddecorator).`
);
export const addLoader = (loader: LoaderFunction, deprecationWarning = true) => {
if (!singleton)
throw new Error(`Singleton client API not yet initialized, cannot call addParameters`);
if (deprecationWarning) addLoaderDeprecationWarning();
singleton.addLoader(loader);
};
export const addArgTypesEnhancer = (enhancer: ArgTypesEnhancer) => {
if (!singleton)
throw new Error(`Singleton client API not yet initialized, cannot call addArgTypesEnhancer`);
@ -99,7 +114,7 @@ export default class ClientApi {
);
addDecorator = (decorator: DecoratorFunction) => {
this._storyStore.addGlobalMetadata({ decorators: [decorator], parameters: {} });
this._storyStore.addGlobalMetadata({ decorators: [decorator] });
};
clearDecorators = deprecate(
@ -114,7 +129,11 @@ export default class ClientApi {
);
addParameters = (parameters: Parameters) => {
this._storyStore.addGlobalMetadata({ decorators: [], parameters });
this._storyStore.addGlobalMetadata({ parameters });
};
addLoader = (loader: LoaderFunction) => {
this._storyStore.addGlobalMetadata({ loaders: [loader] });
};
addArgTypesEnhancer = (enhancer: ArgTypesEnhancer) => {
@ -161,6 +180,7 @@ export default class ClientApi {
kind: kind.toString(),
add: () => api,
addDecorator: () => api,
addLoader: () => api,
addParameters: () => api,
};
@ -196,7 +216,7 @@ export default class ClientApi {
const fileName = m && m.id ? `${m.id}` : undefined;
const { decorators, ...storyParameters } = parameters;
const { decorators, loaders, ...storyParameters } = parameters;
this._storyStore.addStory(
{
id,
@ -205,6 +225,7 @@ export default class ClientApi {
storyFn,
parameters: { fileName, ...storyParameters },
decorators,
loaders,
},
{
applyDecorators: applyHooks(this._decorateStory),
@ -218,7 +239,14 @@ export default class ClientApi {
throw new Error(`You cannot add a decorator after the first story for a kind.
Read more here: https://github.com/storybookjs/storybook/blob/master/MIGRATION.md#can-no-longer-add-decorators-parameters-after-stories`);
this._storyStore.addKindMetadata(kind, { decorators: [decorator], parameters: [] });
this._storyStore.addKindMetadata(kind, { decorators: [decorator] });
return api;
};
api.addLoader = (loader: LoaderFunction) => {
if (hasAdded) throw new Error(`You cannot add a loader after the first story for a kind.`);
this._storyStore.addKindMetadata(kind, { loaders: [loader] });
return api;
};
@ -227,7 +255,7 @@ Read more here: https://github.com/storybookjs/storybook/blob/master/MIGRATION.m
throw new Error(`You cannot add parameters after the first story for a kind.
Read more here: https://github.com/storybookjs/storybook/blob/master/MIGRATION.md#can-no-longer-add-decorators-parameters-after-stories`);
this._storyStore.addKindMetadata(kind, { decorators: [], parameters });
this._storyStore.addKindMetadata(kind, { parameters });
return api;
};

View File

@ -15,14 +15,25 @@ const defaultContext: StoryContext = {
globals: {},
};
/**
* When you call the story function inside a decorator, e.g.:
*
* ```jsx
* <div>{storyFn({ foo: 'bar' })}</div>
* ```
*
* This will override the `foo` property on the `innerContext`, which gets
* merged in with the default context
*/
export const decorateStory = (storyFn: StoryFn, decorator: DecoratorFunction) => {
return (context: StoryContext = defaultContext) =>
decorator(
// You cannot override the parameters key, it is fixed
({ parameters, ...innerContext }: StoryContextUpdate = {}) =>
storyFn({ ...context, ...innerContext }),
context
);
};
export const defaultDecorateStory = (storyFn: StoryFn, decorators: DecoratorFunction[]) =>
decorators.reduce(
(decorated, decorator) => (context: StoryContext = defaultContext) =>
decorator(
// You cannot override the parameters key, it is fixed
({ parameters, ...innerContext }: StoryContextUpdate = {}) =>
decorated({ ...context, ...innerContext }),
context
),
storyFn
);
decorators.reduce(decorateStory, storyFn);

View File

@ -1,4 +1,9 @@
import ClientApi, { addDecorator, addParameters, addArgTypesEnhancer } from './client_api';
import ClientApi, {
addDecorator,
addParameters,
addLoader,
addArgTypesEnhancer,
} from './client_api';
import { defaultDecorateStory } from './decorators';
import { combineParameters } from './parameters';
import StoryStore from './story_store';
@ -19,6 +24,7 @@ export {
ClientApi,
addDecorator,
addParameters,
addLoader,
addArgTypesEnhancer,
combineParameters,
StoryStore,

View File

@ -5,6 +5,7 @@ import stable from 'stable';
import mapValues from 'lodash/mapValues';
import pick from 'lodash/pick';
import store from 'store2';
import deprecate from 'util-deprecate';
import { Channel } from '@storybook/channels';
import Events from '@storybook/core-events';
@ -127,7 +128,7 @@ export default class StoryStore {
// We store global args in session storage. Note that when we finish
// configuring below we will ensure we only use values here that make sense
this._globals = store.session.get(STORAGE_KEY)?.globals || {};
this._globalMetadata = { parameters: {}, decorators: [] };
this._globalMetadata = { parameters: {}, decorators: [], loaders: [] };
this._kinds = {};
this._stories = {};
this._argTypesEnhancers = [ensureArgTypes];
@ -235,7 +236,7 @@ export default class StoryStore {
this.pushToManager();
}
addGlobalMetadata({ parameters, decorators }: StoryMetadata) {
addGlobalMetadata({ parameters = {}, decorators = [], loaders = [] }: StoryMetadata) {
if (parameters) {
const { args, argTypes } = parameters;
if (args || argTypes)
@ -248,13 +249,18 @@ export default class StoryStore {
this._globalMetadata.parameters = combineParameters(globalParameters, parameters);
decorators.forEach((decorator) => {
if (this._globalMetadata.decorators.includes(decorator)) {
logger.warn('You tried to add a duplicate decorator, this is not expected', decorator);
} else {
this._globalMetadata.decorators.push(decorator);
}
});
function _safeAdd(items: any[], collection: any[], caption: string) {
items.forEach((item) => {
if (collection.includes(item)) {
logger.warn(`You tried to add a duplicate ${caption}, this is not expected`, item);
} else {
collection.push(item);
}
});
}
_safeAdd(decorators, this._globalMetadata.decorators, 'decorator');
_safeAdd(loaders, this._globalMetadata.loaders, 'loader');
}
clearGlobalDecorators() {
@ -267,11 +273,12 @@ export default class StoryStore {
order: Object.keys(this._kinds).length,
parameters: {},
decorators: [],
loaders: [],
};
}
}
addKindMetadata(kind: string, { parameters, decorators }: StoryMetadata) {
addKindMetadata(kind: string, { parameters = {}, decorators = [], loaders = [] }: StoryMetadata) {
this.ensureKind(kind);
if (parameters) {
checkGlobals(parameters);
@ -280,6 +287,7 @@ export default class StoryStore {
this._kinds[kind].parameters = combineParameters(this._kinds[kind].parameters, parameters);
this._kinds[kind].decorators.push(...decorators);
this._kinds[kind].loaders.push(...loaders);
}
addArgTypesEnhancer(argTypesEnhancer: ArgTypesEnhancer) {
@ -306,6 +314,7 @@ export default class StoryStore {
storyFn: original,
parameters: storyParameters = {},
decorators: storyDecorators = [],
loaders: storyLoaders = [],
}: AddStoryArgs,
{
applyDecorators,
@ -352,6 +361,7 @@ export default class StoryStore {
...kindMetadata.decorators,
...this._globalMetadata.decorators,
];
const loaders = [...this._globalMetadata.loaders, ...kindMetadata.loaders, ...storyLoaders];
const finalStoryFn = (context: StoryContext) => {
const { passArgsFirst = true } = context.parameters;
@ -396,10 +406,31 @@ export default class StoryStore {
const storyParametersWithArgTypes = { ...storyParameters, argTypes, __isArgsStory };
const storyFn: LegacyStoryFn = (runtimeContext: StoryContext) =>
getDecorated()({
const storyFn: LegacyStoryFn = deprecate(
(runtimeContext: StoryContext) =>
getDecorated()({
...identification,
...runtimeContext,
// Calculate "combined" parameters at render time (NOTE: for perf we could just use combinedParameters from above?)
parameters: this.combineStoryParameters(storyParametersWithArgTypes, kind),
hooks,
args: _stories[id].args,
argTypes,
globals: this._globals,
viewMode: this._selection?.viewMode,
}),
dedent`
\`storyFn\` is deprecated and will be removed in Storybook 7.0.
https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#deprecated-storyfn
`
);
const unboundStoryFn: LegacyStoryFn = (context: StoryContext) => getDecorated()(context);
const applyLoaders = async () => {
const context = {
...identification,
...runtimeContext,
// Calculate "combined" parameters at render time (NOTE: for perf we could just use combinedParameters from above?)
parameters: this.combineStoryParameters(storyParametersWithArgTypes, kind),
hooks,
@ -407,7 +438,11 @@ export default class StoryStore {
argTypes,
globals: this._globals,
viewMode: this._selection?.viewMode,
});
};
const loadResults = await Promise.all(loaders.map((loader) => loader(context)));
const loaded = Object.assign({}, ...loadResults);
return { ...context, loaded };
};
// Pull out parameters.args.$ || .argTypes.$.defaultValue into initialArgs
const passedArgs: Args = combinedParameters.args;
@ -425,7 +460,9 @@ export default class StoryStore {
hooks,
getDecorated,
getOriginal,
applyLoaders,
storyFn,
unboundStoryFn,
parameters: storyParametersWithArgTypes,
args: initialArgs,

View File

@ -11,6 +11,7 @@ import {
ArgTypes,
StoryApi,
DecoratorFunction,
LoaderFunction,
DecorateStoryFunction,
StoryContext,
} from '@storybook/addons';
@ -24,8 +25,9 @@ export interface ErrorLike {
// Metadata about a story that can be set at various levels: global, for a kind, or for a single story.
export interface StoryMetadata {
parameters: Parameters;
decorators: DecoratorFunction[];
parameters?: Parameters;
decorators?: DecoratorFunction[];
loaders?: LoaderFunction[];
}
export type ArgTypesEnhancer = (context: StoryContext) => ArgTypes;
@ -45,13 +47,16 @@ export type AddStoryArgs = StoryIdentifier & {
storyFn: StoryFn<any>;
parameters?: Parameters;
decorators?: DecoratorFunction[];
loaders?: LoaderFunction[];
};
export type StoreItem = StoryIdentifier & {
parameters: Parameters;
getDecorated: () => StoryFn<any>;
getOriginal: () => StoryFn<any>;
applyLoaders: () => Promise<StoryContext>;
storyFn: StoryFn<any>;
unboundStoryFn: StoryFn<any>;
hooks: HooksContext;
args: Args;
initialArgs: Args;

View File

@ -13,7 +13,13 @@ import {
STORY_RENDERED,
} from '@storybook/core-events';
import { toId } from '@storybook/csf';
import addons, { StoryKind, StoryName, Parameters } from '@storybook/addons';
import addons, {
StoryFn,
StoryKind,
StoryName,
Parameters,
LoaderFunction,
} from '@storybook/addons';
import ReactDOM from 'react-dom';
import { StoryRenderer } from './StoryRenderer';
@ -29,10 +35,10 @@ jest.mock('@storybook/client-logger', () => ({
}));
function prepareRenderer() {
const render = jest.fn();
const render = jest.fn(({ storyFn }) => storyFn());
const channel = createChannel({ page: 'preview' });
addons.setChannel(channel);
const storyStore = new StoryStore({ channel });
const storyStore = new StoryStore({ channel: null });
const renderer = new StoryRenderer({ render, channel, storyStore });
// mock out all the dom-touching functions
@ -50,11 +56,13 @@ function addStory(
storyStore: StoryStore,
kind: StoryKind,
name: StoryName,
parameters: Parameters = {}
parameters: Parameters = {},
loaders: LoaderFunction[] = [],
storyFn: StoryFn = jest.fn()
) {
const id = toId(kind, name);
storyStore.addStory(
{ id, kind, name, storyFn: jest.fn(), parameters },
{ id, kind, name, storyFn, parameters, loaders },
{
applyDecorators: defaultDecorateStory,
}
@ -66,10 +74,13 @@ function addAndSelectStory(
storyStore: StoryStore,
kind: StoryKind,
name: StoryName,
parameters: Parameters = {}
parameters: Parameters = {},
loaders: LoaderFunction[] = undefined
) {
const id = addStory(storyStore, kind, name, parameters);
const storyFn = jest.fn();
const id = addStory(storyStore, kind, name, parameters, loaders, storyFn);
storyStore.setSelection({ storyId: id, viewMode: 'story' });
return storyFn;
}
describe('core.preview.StoryRenderer', () => {
@ -80,6 +91,8 @@ describe('core.preview.StoryRenderer', () => {
channel.on(STORY_RENDERED, onStoryRendered);
addAndSelectStory(storyStore, 'a', '1', { p: 'q' });
await renderer.renderCurrentStory(false);
expect(render).toHaveBeenCalledWith(
expect.objectContaining({
id: 'a--1',
@ -95,7 +108,7 @@ describe('core.preview.StoryRenderer', () => {
);
render.mockClear();
renderer.renderCurrentStory(true);
await renderer.renderCurrentStory(true);
expect(render).toHaveBeenCalledWith(
expect.objectContaining({
forceRender: true,
@ -108,18 +121,91 @@ describe('core.preview.StoryRenderer', () => {
expect(onStoryRendered).toHaveBeenCalledWith('a--1');
});
describe('loaders', () => {
it('loads data asynchronously and passes to stories', async () => {
const { channel, storyStore, renderer } = prepareRenderer();
const onStoryRendered = jest.fn();
channel.on(STORY_RENDERED, onStoryRendered);
const loaders = [async () => new Promise((r) => setTimeout(() => r({ foo: 7 }), 100))];
const storyFn = addAndSelectStory(storyStore, 'a', '1', {}, loaders);
await renderer.renderCurrentStory(false);
expect(storyFn).toHaveBeenCalledWith(
{},
expect.objectContaining({
id: 'a--1',
kind: 'a',
name: '1',
loaded: { foo: 7 },
})
);
expect(onStoryRendered).toHaveBeenCalledWith('a--1');
});
it('later loaders override earlier loaders', async () => {
const { channel, storyStore, renderer } = prepareRenderer();
const onStoryRendered = jest.fn();
channel.on(STORY_RENDERED, onStoryRendered);
const loaders = [
async () => new Promise((r) => setTimeout(() => r({ foo: 7 }), 100)),
async () => new Promise((r) => setTimeout(() => r({ foo: 3 }), 50)),
];
const storyFn = addAndSelectStory(storyStore, 'a', '1', {}, loaders);
await renderer.renderCurrentStory(false);
expect(storyFn).toHaveBeenCalledWith(
{},
expect.objectContaining({
id: 'a--1',
kind: 'a',
name: '1',
loaded: { foo: 3 },
})
);
expect(onStoryRendered).toHaveBeenCalledWith('a--1');
});
it('more specific loaders override more generic loaders', async () => {
const { channel, storyStore, renderer } = prepareRenderer();
const onStoryRendered = jest.fn();
channel.on(STORY_RENDERED, onStoryRendered);
storyStore.addGlobalMetadata({ loaders: [async () => ({ foo: 1, bar: 1, baz: 1 })] });
storyStore.addKindMetadata('a', { loaders: [async () => ({ foo: 3, bar: 3 })] });
const storyFn = addAndSelectStory(storyStore, 'a', '1', {}, [async () => ({ foo: 5 })]);
await renderer.renderCurrentStory(false);
expect(storyFn).toHaveBeenCalledWith(
{},
expect.objectContaining({
id: 'a--1',
kind: 'a',
name: '1',
loaded: { foo: 5, bar: 3, baz: 1 },
})
);
expect(onStoryRendered).toHaveBeenCalledWith('a--1');
});
});
describe('errors', () => {
it('renders an error if a config error is set on the store', () => {
it('renders an error if a config error is set on the store', async () => {
const { render, storyStore, renderer } = prepareRenderer();
const err = { message: 'message', stack: 'stack' };
storyStore.setError(err);
storyStore.finishConfiguring();
await renderer.renderCurrentStory(false);
expect(render).not.toHaveBeenCalled();
expect(renderer.showErrorDisplay).toHaveBeenCalledWith(err);
});
it('renders an error if the story calls renderError', () => {
it('renders an error if the story calls renderError', async () => {
const { render, channel, storyStore, renderer } = prepareRenderer();
const onStoryErrored = jest.fn();
@ -129,6 +215,7 @@ describe('core.preview.StoryRenderer', () => {
render.mockImplementation(({ showError }) => showError(err));
addAndSelectStory(storyStore, 'a', '1');
await renderer.renderCurrentStory(false);
expect(renderer.showErrorDisplay).toHaveBeenCalledWith({
message: 'title',
@ -137,7 +224,7 @@ describe('core.preview.StoryRenderer', () => {
expect(onStoryErrored).toHaveBeenCalledWith(err);
});
it('renders an exception if the story calls renderException', () => {
it('renders an exception if the story calls renderException', async () => {
const { render, channel, storyStore, renderer } = prepareRenderer();
const onStoryThrewException = jest.fn();
@ -147,12 +234,13 @@ describe('core.preview.StoryRenderer', () => {
render.mockImplementation(({ showException }) => showException(err));
addAndSelectStory(storyStore, 'a', '1');
await renderer.renderCurrentStory(false);
expect(renderer.showErrorDisplay).toHaveBeenCalledWith(err);
expect(onStoryThrewException).toHaveBeenCalledWith(err);
});
it('renders an exception if the render function throws', () => {
it('renders an exception if the render function throws', async () => {
const { render, channel, storyStore, renderer } = prepareRenderer();
const onStoryThrewException = jest.fn();
@ -164,12 +252,13 @@ describe('core.preview.StoryRenderer', () => {
});
addAndSelectStory(storyStore, 'a', '1');
await renderer.renderCurrentStory(false);
expect(renderer.showErrorDisplay).toHaveBeenCalledWith(err);
expect(onStoryThrewException).toHaveBeenCalledWith(err);
});
it('renders an error if the story is missing', () => {
it('renders an error if the story is missing', async () => {
const { render, channel, storyStore, renderer } = prepareRenderer();
const onStoryMissing = jest.fn();
@ -177,6 +266,7 @@ describe('core.preview.StoryRenderer', () => {
addStory(storyStore, 'a', '1');
storyStore.setSelection({ storyId: 'b--2', viewMode: 'story' });
await renderer.renderCurrentStory(false);
expect(render).not.toHaveBeenCalled();
@ -186,8 +276,8 @@ describe('core.preview.StoryRenderer', () => {
});
describe('docs mode', () => {
it('renders docs and emits when rendering a docs story', () => {
const { render, channel, storyStore } = prepareRenderer();
it('renders docs and emits when rendering a docs story', async () => {
const { render, channel, storyStore, renderer } = prepareRenderer();
const onDocsRendered = jest.fn();
channel.on(DOCS_RENDERED, onDocsRendered);
@ -197,6 +287,7 @@ describe('core.preview.StoryRenderer', () => {
addStory(storyStore, 'a', '1');
storyStore.setSelection({ storyId: 'a--1', viewMode: 'docs' });
await renderer.renderCurrentStory(false);
// Although the docs React component may ultimately render the story we are mocking out
// `react-dom` and just check that *something* is being rendered by react at this point
@ -204,10 +295,11 @@ describe('core.preview.StoryRenderer', () => {
expect(onDocsRendered).toHaveBeenCalledWith('a');
});
it('hides the root and shows the docs root as well as main when rendering a docs story', () => {
it('hides the root and shows the docs root as well as main when rendering a docs story', async () => {
const { storyStore, renderer } = prepareRenderer();
addStory(storyStore, 'a', '1');
storyStore.setSelection({ storyId: 'a--1', viewMode: 'docs' });
await renderer.renderCurrentStory(false);
expect(renderer.showDocs).toHaveBeenCalled();
expect(renderer.showMain).toHaveBeenCalled();
@ -236,15 +328,16 @@ describe('core.preview.StoryRenderer', () => {
});
describe('re-rendering behaviour', () => {
it('does not re-render if nothing changed', () => {
it('does not re-render if nothing changed', async () => {
const { render, channel, storyStore, renderer } = prepareRenderer();
addAndSelectStory(storyStore, 'a', '1');
await renderer.renderCurrentStory(false);
const onStoryUnchanged = jest.fn();
channel.on(STORY_UNCHANGED, onStoryUnchanged);
render.mockClear();
renderer.renderCurrentStory(false);
await renderer.renderCurrentStory(false);
expect(render).not.toHaveBeenCalled();
// Not sure why STORY_UNCHANGED is called with all this stuff
expect(onStoryUnchanged).toHaveBeenCalledWith({
@ -255,36 +348,40 @@ describe('core.preview.StoryRenderer', () => {
getDecorated: expect.any(Function),
});
});
it('does re-render the current story if it has not changed if forceRender is true', () => {
it('does re-render the current story if it has not changed if forceRender is true', async () => {
const { render, channel, storyStore, renderer } = prepareRenderer();
addAndSelectStory(storyStore, 'a', '1');
await renderer.renderCurrentStory(false);
const onStoryChanged = jest.fn();
channel.on(STORY_CHANGED, onStoryChanged);
render.mockClear();
renderer.renderCurrentStory(true);
await renderer.renderCurrentStory(true);
expect(render).toHaveBeenCalled();
expect(onStoryChanged).not.toHaveBeenCalled();
});
it('does re-render if the selected story changes', () => {
const { render, channel, storyStore } = prepareRenderer();
it('does re-render if the selected story changes', async () => {
const { render, channel, storyStore, renderer } = prepareRenderer();
addStory(storyStore, 'a', '1');
addAndSelectStory(storyStore, 'a', '2');
await renderer.renderCurrentStory(false);
const onStoryChanged = jest.fn();
channel.on(STORY_CHANGED, onStoryChanged);
render.mockClear();
storyStore.setSelection({ storyId: 'a--1', viewMode: 'story' });
await renderer.renderCurrentStory(false);
expect(render).toHaveBeenCalled();
expect(onStoryChanged).toHaveBeenCalledWith('a--1');
});
it('does re-render if the story implementation changes', () => {
const { render, channel, storyStore } = prepareRenderer();
it('does re-render if the story implementation changes', async () => {
const { render, channel, storyStore, renderer } = prepareRenderer();
addAndSelectStory(storyStore, 'a', '1');
await renderer.renderCurrentStory(false);
const onStoryChanged = jest.fn();
channel.on(STORY_CHANGED, onStoryChanged);
@ -292,80 +389,93 @@ describe('core.preview.StoryRenderer', () => {
render.mockClear();
storyStore.removeStoryKind('a');
addAndSelectStory(storyStore, 'a', '1');
expect(render).toHaveBeenCalled();
await renderer.renderCurrentStory(false);
expect(render).toHaveBeenCalled();
expect(onStoryChanged).not.toHaveBeenCalled();
});
it('does re-render if the view mode changes', () => {
const { render, channel, storyStore } = prepareRenderer();
it('does re-render if the view mode changes', async () => {
const { render, channel, storyStore, renderer } = prepareRenderer();
addAndSelectStory(storyStore, 'a', '1');
storyStore.setSelection({ storyId: 'a--1', viewMode: 'docs' });
await renderer.renderCurrentStory(false);
const onStoryChanged = jest.fn();
channel.on(STORY_CHANGED, onStoryChanged);
render.mockClear();
storyStore.setSelection({ storyId: 'a--1', viewMode: 'story' });
expect(render).toHaveBeenCalled();
await renderer.renderCurrentStory(false);
expect(render).toHaveBeenCalled();
expect(onStoryChanged).toHaveBeenCalledWith('a--1');
});
});
describe('hooks', () => {
it('cleans up kind hooks when changing kind in docs mode', () => {
const { storyStore } = prepareRenderer();
it('cleans up kind hooks when changing kind in docs mode', async () => {
const { storyStore, renderer } = prepareRenderer();
addAndSelectStory(storyStore, 'a', '1');
addAndSelectStory(storyStore, 'b', '1');
storyStore.setSelection({ storyId: 'a--1', viewMode: 'docs' });
await renderer.renderCurrentStory(false);
storyStore.cleanHooksForKind = jest.fn();
storyStore.setSelection({ storyId: 'b--1', viewMode: 'docs' });
await renderer.renderCurrentStory(false);
expect(storyStore.cleanHooksForKind).toHaveBeenCalledWith('a');
});
it('does not clean up hooks when changing story but not kind in docs mode', () => {
const { storyStore } = prepareRenderer();
it('does not clean up hooks when changing story but not kind in docs mode', async () => {
const { storyStore, renderer } = prepareRenderer();
addAndSelectStory(storyStore, 'a', '1');
addAndSelectStory(storyStore, 'a', '2');
storyStore.setSelection({ storyId: 'a--1', viewMode: 'docs' });
await renderer.renderCurrentStory(false);
storyStore.cleanHooksForKind = jest.fn();
storyStore.setSelection({ storyId: 'a--2', viewMode: 'docs' });
await renderer.renderCurrentStory(false);
expect(storyStore.cleanHooksForKind).not.toHaveBeenCalled();
});
it('cleans up kind hooks when changing view mode from docs', () => {
const { storyStore } = prepareRenderer();
it('cleans up kind hooks when changing view mode from docs', async () => {
const { storyStore, renderer } = prepareRenderer();
addAndSelectStory(storyStore, 'a', '1');
storyStore.setSelection({ storyId: 'a--1', viewMode: 'docs' });
await renderer.renderCurrentStory(false);
storyStore.cleanHooksForKind = jest.fn();
storyStore.setSelection({ storyId: 'a--1', viewMode: 'story' });
await renderer.renderCurrentStory(false);
expect(storyStore.cleanHooksForKind).toHaveBeenCalledWith('a');
});
it('cleans up story hooks when changing story in story mode', () => {
const { storyStore } = prepareRenderer();
it('cleans up story hooks when changing story in story mode', async () => {
const { storyStore, renderer } = prepareRenderer();
addStory(storyStore, 'a', '1');
addAndSelectStory(storyStore, 'a', '2');
await renderer.renderCurrentStory(false);
storyStore.cleanHooks = jest.fn();
storyStore.setSelection({ storyId: 'a--1', viewMode: 'story' });
await renderer.renderCurrentStory(false);
expect(storyStore.cleanHooks).toHaveBeenCalledWith('a--2');
});
it('cleans up story hooks when changing view mode from story', () => {
const { storyStore } = prepareRenderer();
it('cleans up story hooks when changing view mode from story', async () => {
const { storyStore, renderer } = prepareRenderer();
addAndSelectStory(storyStore, 'a', '1');
await renderer.renderCurrentStory(false);
storyStore.cleanHooks = jest.fn();
storyStore.setSelection({ storyId: 'a--1', viewMode: 'docs' });
await renderer.renderCurrentStory(false);
expect(storyStore.cleanHooks).toHaveBeenCalledWith('a--1');
});

View File

@ -106,7 +106,7 @@ export class StoryRenderer {
this.renderCurrentStory(true);
}
renderCurrentStory(forceRender: boolean) {
async renderCurrentStory(forceRender: boolean) {
const { storyStore } = this;
const loadError = storyStore.getError();
@ -140,10 +140,10 @@ export class StoryRenderer {
showException: (err: Error) => this.renderException(err),
};
this.renderStoryIfChanged({ metadata, context });
await this.renderStoryIfChanged({ metadata, context });
}
renderStoryIfChanged({
async renderStoryIfChanged({
metadata,
context,
}: {
@ -217,7 +217,7 @@ export class StoryRenderer {
}
case 'story':
default: {
this.renderStory({ context });
await this.renderStory({ context });
break;
}
}
@ -273,16 +273,17 @@ export class StoryRenderer {
document.getElementById('root').removeAttribute('hidden');
}
renderStory({ context, context: { id, getDecorated } }: { context: RenderContext }) {
async renderStory({ context, context: { id, getDecorated } }: { context: RenderContext }) {
if (getDecorated) {
(async () => {
try {
await this.render(context);
this.channel.emit(Events.STORY_RENDERED, id);
} catch (err) {
this.renderException(err);
}
})();
try {
const { applyLoaders, unboundStoryFn } = context;
const storyContext = await applyLoaders();
const storyFn = () => unboundStoryFn(storyContext);
await this.render({ ...context, storyFn });
this.channel.emit(Events.STORY_RENDERED, id);
} catch (err) {
this.renderException(err);
}
} else {
this.showNoPreview();
this.channel.emit(Events.STORY_MISSING, id);

View File

@ -82,7 +82,7 @@ describe('core.preview.loadCsf', () => {
const mockedStoriesOf = clientApi.storiesOf as jest.Mock;
expect(mockedStoriesOf).toHaveBeenCalledWith('a', true);
const aApi = mockedStoriesOf.mock.results[0].value;
const extras: any = { decorators: [], args: {}, argTypes: {} };
const extras: any = { decorators: [], args: {}, argTypes: {}, loaders: [] };
expect(aApi.add).toHaveBeenCalledWith('1', input.a[1], { __id: 'a--1', ...extras });
expect(aApi.add).toHaveBeenCalledWith('2', input.a[2], { __id: 'a--2', ...extras });
@ -181,6 +181,7 @@ describe('core.preview.loadCsf', () => {
decorators: [],
args: {},
argTypes: {},
loaders: [],
});
});
@ -264,6 +265,7 @@ describe('core.preview.loadCsf', () => {
__id: 'a--x',
args: { b: 1 },
argTypes: { b: 'string' },
loaders: [],
});
expect(logger.debug).toHaveBeenCalled();
});
@ -295,6 +297,7 @@ describe('core.preview.loadCsf', () => {
__id: 'a--x',
args: { b: 1 },
argTypes: { b: 'string' },
loaders: [],
});
expect(logger.debug).not.toHaveBeenCalled();
});
@ -334,6 +337,7 @@ describe('core.preview.loadCsf', () => {
__id: 'a--x',
args: { b: 1, c: 2 },
argTypes: { b: 'string', c: 'number' },
loaders: [],
});
expect(logger.debug).toHaveBeenCalled();
});

View File

@ -115,6 +115,7 @@ const loadStories = (
id: componentId,
parameters: kindParameters,
decorators: kindDecorators,
loaders: kindLoaders = [],
component,
subcomponents,
args: kindArgs,
@ -146,6 +147,10 @@ const loadStories = (
kind.addDecorator(decorator);
});
kindLoaders.forEach((loader: any) => {
kind.addLoader(loader);
});
const storyExports = Object.keys(exports);
if (storyExports.length === 0) {
logger.warn(
@ -167,6 +172,7 @@ const loadStories = (
// storyFn.x taking precedence in the merge
const parameters = { ...story?.parameters, ...storyFn.parameters };
const decorators = [...(storyFn.decorators || []), ...(story?.decorators || [])];
const loaders = [...(storyFn.loaders || []), ...(story?.loaders || [])];
const args = { ...story?.args, ...storyFn.args };
const argTypes = { ...story?.argTypes, ...storyFn.argTypes };
@ -180,6 +186,7 @@ const loadStories = (
...parameters,
__id: toId(componentId || kindName, exportName),
decorators,
loaders,
args,
argTypes,
};

View File

@ -45,7 +45,6 @@ it('returns apis', () => {
});
it('reuses the current client api when the lib is reloaded', () => {
jest.useFakeTimers();
const render = jest.fn();
const { clientApi } = start(render);
@ -58,8 +57,12 @@ it('reuses the current client api when the lib is reloaded', () => {
expect(clientApi).toEqual(valueOfClientApi);
});
it('calls render when you add a story', () => {
jest.useFakeTimers();
// With async rendering we need to wait for various promises to resolve.
// Sleeping for 0 ms allows all the async (but instantaneous) calls to run
// through the event loop.
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
it('calls render when you add a story', async () => {
const render = jest.fn();
const { clientApi, configApi } = start(render);
@ -68,11 +71,11 @@ it('calls render when you add a story', () => {
clientApi.storiesOf('kind', {} as NodeModule).add('story', () => {});
}, {} as NodeModule);
await sleep(0);
expect(render).toHaveBeenCalledWith(expect.objectContaining({ kind: 'kind', name: 'story' }));
});
it('emits an exception and shows error when your story throws', () => {
jest.useFakeTimers();
it('emits an exception and shows error when your story throws', async () => {
const render = jest.fn().mockImplementation(() => {
throw new Error('Some exception');
});
@ -83,12 +86,12 @@ it('emits an exception and shows error when your story throws', () => {
clientApi.storiesOf('kind', {} as NodeModule).add('story1', () => {});
}, {} as NodeModule);
await sleep(0);
expect(render).toHaveBeenCalled();
expect(document.body.classList.add).toHaveBeenCalledWith('sb-show-errordisplay');
});
it('emits an error and shows error when your framework calls showError', () => {
jest.useFakeTimers();
it('emits an error and shows error when your framework calls showError', async () => {
const error = {
title: 'Some error',
description: 'description',
@ -103,6 +106,7 @@ it('emits an error and shows error when your framework calls showError', () => {
clientApi.storiesOf('kind', {} as NodeModule).add('story', () => {});
}, {} as NodeModule);
await sleep(0);
expect(render).toHaveBeenCalled();
expect(document.body.classList.add).toHaveBeenCalledWith('sb-show-errordisplay');
});

View File

@ -1,9 +1,10 @@
/* eslint-disable import/no-unresolved */
import { addDecorator, addParameters, addArgTypesEnhancer } from '{{clientApi}}';
import { addDecorator, addParameters, addLoader, addArgTypesEnhancer } from '{{clientApi}}';
import { logger } from '{{clientLogger}}';
import {
decorators,
parameters,
loaders,
argTypesEnhancers,
globals,
globalTypes,
@ -17,6 +18,9 @@ if (args || argTypes) {
if (decorators) {
decorators.forEach((decorator) => addDecorator(decorator, false));
}
if (loaders) {
loaders.forEach((loader) => addLoader(loader, false));
}
if (parameters || globals || globalTypes) {
addParameters({ ...parameters, globals, globalTypes }, false);
}

View File

@ -10,7 +10,7 @@ const branch = execSync('git rev-parse --abbrev-ref HEAD').toString().trim();
const branchToHook = {
master: FRONTPAGE_WEBHOOK,
'next': FRONTPAGE_WEBHOOK_NEXT,
next: FRONTPAGE_WEBHOOK_NEXT,
};
console.log('build-frontpage');