Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Multistory and Preview Decorator Support #1394

Closed
wants to merge 14 commits into from
7 changes: 7 additions & 0 deletions addons/options/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,15 @@ setOptions({
* @type {String}
*/
selectedAddonPanel: undefined, // The order of addons in the "Addon panel" is the same as you import them in 'addons.js'. The first panel will be opened by default as you run Storybook

/**
* regular expression to separate multistories section
* @type {Regex}
*/
multistorySeparator: /:/,
});

storybook.configure(() => require('./stories'), module);
```

It is also possible to call `setOptions()` inside individual stories. Note that this will bring impact story render performance significantly.
1 change: 1 addition & 0 deletions addons/options/preview.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const preview = require('./dist/preview');

exports.setOptions = preview.setOptions;
// exports.setPreviewOptionsFn = preview.setPreviewOptionsFn;
preview.init();
15 changes: 11 additions & 4 deletions addons/options/src/preview/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,19 @@ export function init() {
// NOTE nothing to do here
}

// let previewOptions = () => {};

function regExpStringify(exp) {
if (typeof exp === 'string') return exp;
if (Object.prototype.toString.call(exp) === '[object RegExp]') return exp.source;
return null;
if (!exp) return null;
if (Object.prototype.toString.call(exp) === '[object String]') return exp.source;
if (Object.prototype.toString.call(exp) !== '[object RegExp]') return null;
return exp.source;
}

// export function setPreviewOptionsFn(previewOptionsFn) {
// previewOptions = previewOptionsFn;
// }

// setOptions function will send Storybook UI options when the channel is
// ready. If called before, options will be cached until it can be sent.
export function setOptions(newOptions) {
Expand All @@ -22,7 +29,6 @@ export function setOptions(newOptions) {
'Failed to find addon channel. This may be due to https://github.com/storybooks/storybook/issues/1192.'
);
}

let options = newOptions;

// since 'undefined' and 'null' are the valid values we don't want to
Expand All @@ -31,6 +37,7 @@ export function setOptions(newOptions) {
options = {
...newOptions,
hierarchySeparator: regExpStringify(newOptions.hierarchySeparator),
multistorySeparator: regExpStringify(newOptions.multistorySeparator),
};
}

Expand Down
9 changes: 9 additions & 0 deletions app/react/src/client/preview/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import { createStore } from 'redux';
import addons from '@storybook/addons';
// import { setPreviewOptionsFn } from '@storybook/addon-options';
import createChannel from '@storybook/channel-postmessage';
import qs from 'qs';
// import pick from 'lodash.pick';
import StoryStore from './story_store';
import ClientApi from './client_api';
import ConfigApi from './config_api';
Expand Down Expand Up @@ -53,3 +55,10 @@ const renderUI = () => {
};

reduxStore.subscribe(renderUI);

// function previewOptions(options) {
// const renderOpt = pick(options, ['multistorySeparator', 'previewDecorator']);
// if (renderOptions(renderOpt)) render(context);
// }

// setPreviewOptionsFn(previewOptions);
191 changes: 159 additions & 32 deletions app/react/src/client/preview/render.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/* global document */
/* eslint react/prop-types: 0 */
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why ?


import React from 'react';
import ReactDOM from 'react-dom';
Expand All @@ -11,6 +12,30 @@ const isBrowser = typeof window !== 'undefined';

const logger = console;

const options = {
multistorySeparator: /:/,
previewDecorator: stories => <div className="stories-root">{stories}</div>,
};

export function renderOptions(newOptions) {
let keepSameSeparator = true;
if (newOptions.multistorySeparator) {
const multistorySeparator = new RegExp(newOptions.multistorySeparator);
keepSameSeparator = multistorySeparator.source === options.multistorySeparator.source;
options.multistorySeparator = multistorySeparator;
}

let keepSameDecorator = true;
if (typeof newOptions.previewDecorator === 'function') {
keepSameDecorator = newOptions.previewDecorator === options.previewDecorator;
options.previewDecorator = newOptions.previewDecorator;
}

return !keepSameSeparator || !keepSameDecorator;
}

const elmentID = (kind, story) => `${kind}-${story}`;

let rootEl = null;
let previousKind = '';
let previousStory = '';
Expand All @@ -19,12 +44,17 @@ if (isBrowser) {
rootEl = document.getElementById('root');
}

export function renderError(error) {
// added this function to use with tests
export function setRootEl(root) {
rootEl = root;
}

function errorElement(error) {
const properError = new Error(error.title);
properError.stack = error.description;

const redBox = <ErrorDisplay error={properError} />;
ReactDOM.render(redBox, rootEl);
return redBox;
}

export function renderException(error) {
Expand All @@ -37,20 +67,91 @@ export function renderException(error) {

// Log the stack to the console. So, user could check the source code.
logger.error(error.stack);
return error;
}

function renderStory(context, storyStore) {
const { kind, story } = context;

const NoPreview = ({ info }) => <p>{info}</p>;
const noPreview = <NoPreview info="No Preview Available!" />;

const storyFn = storyStore.getStory(kind, story);
if (!storyFn) {
return noPreview;
}
return storyFn(context);
}

function singleElement(context, element) {
const { kind, story } = context;

if (!element) {
const error = {
title: `Expecting a React element from the story: "${story}" of "${kind}".`,
description: stripIndents`
Did you forget to return the React element from the story?
Use "() => (<MyComp/>)" or "() => { return <MyComp/>; }" when defining the story.
`,
};
return errorElement(error);
}

if (!isReactRenderable(element)) {
const error = {
title: `Expecting a valid React element from the story: "${story}" of "${kind}".`,
description: stripIndents`
Seems like you are not returning a correct React element from the story.
Could you double check that?
`,
};
return errorElement(error);
}

return element;
}

function multiElement(kindRoot, selectedStory, storyStore, onStoryDidMount) {
const kinds = storyStore.getStoryKinds();
const selectedKinds = kinds.filter(kind => kind.match(`^${kindRoot}`));

const StoryHolder = ({ name, element }) => <div id={name}>{element}</div>;

const stories = selectedKinds.reduce(
(prev, kind) =>
prev.concat(
storyStore.getStories(kind).map(story => ({
kind,
story,
preElement: renderStory(
{ kind, story, kindRoot, selectedStory, onStoryDidMount },
storyStore
),
}))
),
[]
);

return options.previewDecorator(
stories.map(({ kind, story, preElement }) => (
<StoryHolder
key={elmentID(kind, story)}
name={elmentID(kind, story)}
element={singleElement({ kind, story }, preElement)}
/>
))
);
}

export function renderMain(data, storyStore) {
if (storyStore.size() === 0) return null;

const NoPreview = () => <p>No Preview Available!</p>;
const noPreview = <NoPreview />;
const { selectedKind, selectedStory } = data;

const story = storyStore.getStory(selectedKind, selectedStory);
if (!story) {
ReactDOM.render(noPreview, rootEl);
return null;
}
const multiStoriesSeparator =
selectedKind &&
selectedKind.match(options.multistorySeparator) &&
selectedKind.match(options.multistorySeparator)[0];

// Unmount the previous story only if selectedKind or selectedStory has changed.
// renderMain() gets executed after each action. Actions will cause the whole
Expand All @@ -66,37 +167,63 @@ export function renderMain(data, storyStore) {
ReactDOM.unmountComponentAtNode(rootEl);
}

// we pass context to story decorators
// it available on the stories side as:
// storiesOf().addDecorator(() => storyfn, {kind, story, kindRoot, selectedStory, onStoryDidMount})
//
// where in the Multi Story Mode:
// kind: storyKind of current story
// story: current story
// selectedStory: story selected at Stories Panel
// kindRoot: common part of storyKind
// onStoryDidMount: function takes a callback as argument which will be invoked after ReactDOM.render
//
// and in the Single story mode:
// story same as selectedStory
// kind same as kindRoot

let storyDidMount = () => {};
const onStoryDidMount = fn => {
storyDidMount = fn;
};

const context = {
kind: selectedKind,
story: selectedStory,
kindRoot: selectedKind,
selectedStory,
onStoryDidMount,
};

const element = story(context);

if (!element) {
const error = {
title: `Expecting a React element from the story: "${selectedStory}" of "${selectedKind}".`,
description: stripIndents`
Did you forget to return the React element from the story?
Use "() => (<MyComp/>)" or "() => { return <MyComp/>; }" when defining the story.
`,
};
return renderError(error);
}

if (!isReactRenderable(element)) {
const error = {
title: `Expecting a valid React element from the story: "${selectedStory}" of "${selectedKind}".`,
description: stripIndents`
Seems like you are not returning a correct React element from the story.
Could you double check that?
`,
};
return renderError(error);
// const element = multiStoriesSeparator
// ? multiElement(
// selectedKind.split(multiStoriesSeparator)[0].concat(multiStoriesSeparator),
// selectedStory,
// storyStore,
// onStoryDidMount
// )
// : options.previewDecorator(singleElement(context, storyStore));

let element;
if (multiStoriesSeparator) {
element = multiElement(
selectedKind.split(multiStoriesSeparator)[0].concat(multiStoriesSeparator),
selectedStory,
storyStore,
onStoryDidMount
);
} else {
const preElement = renderStory(context, storyStore);
element = options.previewDecorator(singleElement(context, preElement));
}

ReactDOM.render(element, rootEl);
return null;

// we invoke this callback to allow setup behavior via decorators
// use case example:
// (id) => document.getElementById(id).scrollIntoView()
storyDidMount(elmentID(selectedKind, selectedStory));
return element;
}

export default function renderPreview({ reduxStore, storyStore }) {
Expand Down
Loading