Skip to content

Commit

Permalink
Merge pull request #6601 from storybooks/add/addon-contexts/url-query…
Browse files Browse the repository at this point in the history
…-param

Add URL query param feature
  • Loading branch information
leoyli authored Apr 24, 2019
2 parents d29dd10 + d2704db commit 97fbbc0
Show file tree
Hide file tree
Showing 26 changed files with 276 additions and 183 deletions.
5 changes: 5 additions & 0 deletions addons/contexts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ once then apply it everywhere**.
use it to bridge with your favorite routing, state-management solutions, or even your own
[React Context](https://reactjs.org/docs/context.html) provider.
4. Offer chainable and granular configurations. It is even possible to fine-tune at per story level.
5. Visual regression friendly. You can use this addon to driving the same story under different contexts to smoke
testing important visual states.

## 🧰 Requirements

Expand Down Expand Up @@ -227,6 +229,9 @@ be shown at first in the toolbar menu in your Storybook.
4. The addon will persist the selected params (the addon state) between stories at run-time (similar to other
addons). If the active param were gone after story switching, it fallback to the default then the first. As a
rule of thumbs, whenever collisions made possible, always the first wins.
5. Query parameters are supported for pre-selecting contexts param, which comes handy for visual regression testing.
You can do this by appending `&contexts=[name of contexts]=[name of param]` in the URL under iframe mode. Use `,`
to separate multiple contexts (e.g. `&contexts=Theme=Forests,Language=Fr`).

## 📖 License

Expand Down
8 changes: 5 additions & 3 deletions addons/contexts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@
],
"author": "Leo Y. Li",
"license": "MIT",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"main": "dist/register.js",
"files": [
"dist/**/*",
"register.js",
Expand All @@ -27,10 +26,13 @@
},
"dependencies": {
"@storybook/addons": "5.1.0-alpha.33",
"@storybook/channels": "5.1.0-alpha.33",
"@storybook/api": "5.1.0-alpha.33",
"@storybook/components": "5.1.0-alpha.33",
"@storybook/core-events": "5.1.0-alpha.33"
},
"peerDependencies": {
"qs": "*"
},
"optionalDependencies": {
"react": "*",
"vue": "*"
Expand Down
43 changes: 37 additions & 6 deletions addons/contexts/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,38 @@
import { withContexts } from './preview/frameworks/react';
export { withContexts };
export default withContexts;
import { makeDecorator, StoryWrapper } from '@storybook/addons';
import { ContextsPreviewAPI } from './preview/ContextsPreviewAPI';
import { ID, PARAM } from './shared/constants';
import { AddonSetting, AnyFunctionReturns, ContextNode, PropsMap } from './shared/types';

console.error(
`[addon-contexts] Deprecation warning: "import { withContexts } from 'addon-contexts'" has been deprecated. Please import from 'addon-contexts/react' instead.`
);
/**
* This file serves a idiomatic facade of a Storybook decorator.
*
* Wrapper function get called whenever the Storybook rerender the view. This reflow logic is
* framework agnostic; on the other hand, the framework specific bindings are the implementation
* details hidden behind the passed `render` function.
*
* Here, we need a dedicated singleton as a state manager for preview (the addon API, in vanilla)
* who is also knowing how to communicate with the Storybook manager (in React) via the Storybook
* event system.
*
* @param {Render} render - framework specific bindings
*/
export type Render<T> = (...args: [ContextNode[], PropsMap, AnyFunctionReturns<T>]) => T;
type CreateAddonDecorator = <T>(render: Render<T>) => (contexts: AddonSetting[]) => unknown;

export const createAddonDecorator: CreateAddonDecorator = render => {
const wrapper: StoryWrapper = (getStory, context, settings: any) => {
const { getContextNodes, getSelectionState, getPropsMap } = ContextsPreviewAPI();
const nodes = getContextNodes(settings);
const state = getSelectionState();
const props = getPropsMap(nodes, state);
return render(nodes, props, () => getStory(context));
};

return makeDecorator({
name: ID,
parameterName: PARAM,
skipIfNoParametersOrOptions: true,
allowDeprecatedUsage: false,
wrapper,
});
};
31 changes: 0 additions & 31 deletions addons/contexts/src/manager/AddonManager.tsx

This file was deleted.

32 changes: 32 additions & 0 deletions addons/contexts/src/manager/ContextsManager.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useChannel } from './libs/useChannel';
import { ToolBar } from './components/ToolBar';
import { deserialize, serialize } from '../shared/serializers';
import { PARAM, REBOOT_MANAGER, UPDATE_MANAGER, UPDATE_PREVIEW } from '../shared/constants';
import { FCNoChildren, ManagerAPI, SelectionState } from '../shared/types';

/**
* A smart component for handling manager-preview interactions
*/
type ContextsManager = FCNoChildren<{
api: ManagerAPI;
}>;

export const ContextsManager: ContextsManager = ({ api }) => {
const [nodes, setNodes] = useState([]);
const [state, setState] = useState<SelectionState>(deserialize(api.getQueryParam(PARAM)));
const setSelected = useCallback(
(nodeId, name) => setState(obj => ({ ...obj, [nodeId]: name })),
[]
);

// from preview
useChannel(UPDATE_MANAGER, newNodes => setNodes(newNodes), []);

// to preview
useEffect(() => api.emit(REBOOT_MANAGER), []);
useEffect(() => api.emit(UPDATE_PREVIEW, state), [state]);
useEffect(() => api.setQueryParams({ [PARAM]: serialize(state) }), [state]);

return <ToolBar nodes={nodes} state={state || {}} setSelected={setSelected} />;
};
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { ComponentProps } from 'react';
import { Separator } from '@storybook/components';
import { ToolbarControl } from './ToolbarControl';
import { ContextNode, FCNoChildren, SelectionState } from '../types';
import { ContextNode, FCNoChildren, SelectionState } from '../../shared/types';

type ToolBar = FCNoChildren<{
nodes: ContextNode[];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { ComponentProps } from 'react';
import { Icons, IconButton, WithTooltip } from '@storybook/components';
import { ToolBarMenuOptions } from './ToolBarMenuOptions';
import { ContextNode, FCNoChildren } from '../types';
import { ContextNode, FCNoChildren } from '../../shared/types';

type ToolBarMenu = FCNoChildren<{
icon: ContextNode['icon'];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react';
import { TooltipLinkList } from '@storybook/components';
import { OPT_OUT } from '../constants';
import { FCNoChildren } from '../types';
import { OPT_OUT } from '../../shared/constants';
import { FCNoChildren } from '../../shared/types';

type ToolBarMenuOptions = FCNoChildren<{
activeName: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react';
import { ToolBarMenu } from './ToolBarMenu';
import { OPT_OUT } from '../constants';
import { ContextNode, FCNoChildren, Omit } from '../types';
import { OPT_OUT } from '../../shared/constants';
import { ContextNode, FCNoChildren, Omit } from '../../shared/types';

type ToolbarControl = FCNoChildren<
Omit<
Expand All @@ -25,8 +25,8 @@ export const ToolbarControl: ToolbarControl = ({
const [expanded, setExpanded] = React.useState(false);
const paramNames = params.map(({ name }) => name);
const activeName =
// validate the selected name
(paramNames.concat(OPT_OUT).includes(selected) && selected) ||
// validate the integrity of the selected name
(paramNames.concat(options.cancelable && OPT_OUT).includes(selected) && selected) ||
// fallback to default
(params.find(param => !!param.default) || { name: null }).name ||
// fallback to the first
Expand Down
3 changes: 1 addition & 2 deletions addons/contexts/src/manager/libs/useChannel.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
export { Channel } from '@storybook/channels';
import addons from '@storybook/addons';
import { useEffect } from 'react';
import { AnyFunctionReturns } from '../../types';
import { AnyFunctionReturns } from '../../shared/types';

/**
* The React hook version of Storybook Channel API.
Expand Down
81 changes: 81 additions & 0 deletions addons/contexts/src/preview/ContextsPreviewAPI.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import addons from '@storybook/addons';
import { parse } from 'qs';
import { getContextNodes, getPropsMap, getRendererFrom, singleton } from './libs';
import { deserialize } from '../shared/serializers';
import {
PARAM,
REBOOT_MANAGER,
UPDATE_PREVIEW,
UPDATE_MANAGER,
FORCE_RE_RENDER,
SET_CURRENT_STORY,
} from '../shared/constants';
import { ContextNode, PropsMap, SelectionState } from '../shared/types';

/**
* A singleton for handling preview-manager and one-time-only side-effects
*/
export const ContextsPreviewAPI = singleton(() => {
const channel = addons.getChannel();
let contextsNodesMemo: ContextNode[] = null;
let selectionState: SelectionState = {};

/**
* URL query param can be used to predetermine the contexts a story should render,
* which is useful for performing image snapshot testing or URL sharing.
*/
if (window && window.location) {
const contextQuery = parse(window.location.search)[PARAM];
if (contextQuery) {
selectionState = deserialize(contextQuery);
}
}

/**
* (Vue specific)
* Vue will inject getter/setters on the first rendering of the addon,
* which is the reason why we have to keep an internal reference and use `Object.assign` to update it.
*/
let reactivePropsMap = {};
const updateReactiveSystem = (propsMap: PropsMap) =>
/* tslint:disable:prefer-object-spread */
Object.assign(reactivePropsMap, propsMap);

/**
* Preview-manager communications.
*/
// from manager
channel.on(SET_CURRENT_STORY, () => (contextsNodesMemo = null));
channel.on(REBOOT_MANAGER, () => channel.emit(UPDATE_MANAGER, contextsNodesMemo));
channel.on(UPDATE_PREVIEW, state => {
if (state) {
selectionState = state;
channel.emit(FORCE_RE_RENDER);
}
});

// to manager
const getContextNodesWithSideEffects: typeof getContextNodes = (...arg) => {
// we want to notify the manager only when the story changed since `parameter` can be changed
if (contextsNodesMemo === null) {
contextsNodesMemo = getContextNodes(...arg);
channel.emit(UPDATE_MANAGER, contextsNodesMemo);
}
return contextsNodesMemo;
};

/**
* @Public
* Exposed interfaces
*/
return {
// methods get called on Storybook event lifecycle
getContextNodes: getContextNodesWithSideEffects,
getSelectionState: () => selectionState,
getPropsMap,

// methods for processing framework specific bindings
getRendererFrom,
updateReactiveSystem,
};
});
52 changes: 0 additions & 52 deletions addons/contexts/src/preview/api.ts

This file was deleted.

6 changes: 3 additions & 3 deletions addons/contexts/src/preview/frameworks/react.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import React from 'react';
import { createAddonDecorator, Render } from '../index';
import { addonContextsAPI } from '../api';
import { createAddonDecorator, Render } from '../../index';
import { ContextsPreviewAPI } from '../ContextsPreviewAPI';

/**
* This is the framework specific bindings for React.
* '@storybook/react' expects the returning object from a decorator to be a 'React Element' (vNode).
*/
export const renderReact: Render<React.ReactElement> = (contextNodes, propsMap, getStoryVNode) => {
const { getRendererFrom } = addonContextsAPI();
const { getRendererFrom } = ContextsPreviewAPI();
return getRendererFrom(React.createElement)(contextNodes, propsMap, getStoryVNode);
};

Expand Down
8 changes: 4 additions & 4 deletions addons/contexts/src/preview/frameworks/vue.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import Vue from 'vue';
import { createAddonDecorator, Render } from '../index';
import { addonContextsAPI } from '../api';
import { ID } from '../../constants';
import { createAddonDecorator, Render } from '../../index';
import { ContextsPreviewAPI } from '../ContextsPreviewAPI';
import { ID } from '../../shared/constants';

/**
* This is the framework specific bindings for Vue.
* '@storybook/vue' expects the returning object from a decorator to be a 'VueComponent'.
*/
export const renderVue: Render<Vue.Component> = (contextNodes, propsMap, getStoryVNode) => {
const { getRendererFrom, updateReactiveSystem } = addonContextsAPI();
const { getRendererFrom, updateReactiveSystem } = ContextsPreviewAPI();
const reactiveProps = updateReactiveSystem(propsMap);
return Vue.extend({
name: ID,
Expand Down
Loading

0 comments on commit 97fbbc0

Please sign in to comment.