diff --git a/addons/contexts/README.md b/addons/contexts/README.md index ab3169ca4611..505200bbd93f 100644 --- a/addons/contexts/README.md +++ b/addons/contexts/README.md @@ -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 @@ -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 diff --git a/addons/contexts/package.json b/addons/contexts/package.json index e11bdbefbf45..8b6e4bd92a8d 100644 --- a/addons/contexts/package.json +++ b/addons/contexts/package.json @@ -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", @@ -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": "*" diff --git a/addons/contexts/src/index.ts b/addons/contexts/src/index.ts index 33ebe20cd334..d6c0b1c84328 100644 --- a/addons/contexts/src/index.ts +++ b/addons/contexts/src/index.ts @@ -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 = (...args: [ContextNode[], PropsMap, AnyFunctionReturns]) => T; +type CreateAddonDecorator = (render: Render) => (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, + }); +}; diff --git a/addons/contexts/src/manager/AddonManager.tsx b/addons/contexts/src/manager/AddonManager.tsx deleted file mode 100644 index 29fb56ba9fa2..000000000000 --- a/addons/contexts/src/manager/AddonManager.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React, { useEffect, useState, useCallback } from 'react'; -import { useChannel, Channel } from './libs/useChannel'; -import { ToolBar } from './ToolBar'; -import { REBOOT_MANAGER, UPDATE_MANAGER, UPDATE_PREVIEW } from '../constants'; -import { SelectionState, FCNoChildren } from '../types'; - -/** - * Control addon states and addon-story interactions - */ -type AddonManager = FCNoChildren<{ - channel: Channel; -}>; - -export const AddonManager: AddonManager = ({ channel }) => { - const [nodes, setNodes] = useState([]); - const [state, setState] = useState(undefined); - const setSelected = useCallback( - (nodeId, name) => setState((obj = {}) => ({ ...obj, [nodeId]: name })), - [] - ); - - // from preview - useChannel(UPDATE_MANAGER, newNodes => setNodes(newNodes), []); - useChannel(UPDATE_MANAGER, (_, newState) => newState && setState(newState), []); - - // to preview - useEffect(() => channel.emit(REBOOT_MANAGER), []); - useEffect(() => state && channel.emit(UPDATE_PREVIEW, state), [state]); - - return ; -}; diff --git a/addons/contexts/src/manager/ContextsManager.tsx b/addons/contexts/src/manager/ContextsManager.tsx new file mode 100644 index 000000000000..ca856c882f7b --- /dev/null +++ b/addons/contexts/src/manager/ContextsManager.tsx @@ -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(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 ; +}; diff --git a/addons/contexts/src/manager/ToolBar.tsx b/addons/contexts/src/manager/components/ToolBar.tsx similarity index 90% rename from addons/contexts/src/manager/ToolBar.tsx rename to addons/contexts/src/manager/components/ToolBar.tsx index 48c966e62612..6573fd93f0d8 100644 --- a/addons/contexts/src/manager/ToolBar.tsx +++ b/addons/contexts/src/manager/components/ToolBar.tsx @@ -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[]; diff --git a/addons/contexts/src/manager/ToolBarMenu.tsx b/addons/contexts/src/manager/components/ToolBarMenu.tsx similarity index 93% rename from addons/contexts/src/manager/ToolBarMenu.tsx rename to addons/contexts/src/manager/components/ToolBarMenu.tsx index 69ba2ae90d15..7602bdbd1a57 100644 --- a/addons/contexts/src/manager/ToolBarMenu.tsx +++ b/addons/contexts/src/manager/components/ToolBarMenu.tsx @@ -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']; diff --git a/addons/contexts/src/manager/ToolBarMenuOptions.tsx b/addons/contexts/src/manager/components/ToolBarMenuOptions.tsx similarity index 84% rename from addons/contexts/src/manager/ToolBarMenuOptions.tsx rename to addons/contexts/src/manager/components/ToolBarMenuOptions.tsx index 64a1c0de0e13..776cf5ba69bb 100644 --- a/addons/contexts/src/manager/ToolBarMenuOptions.tsx +++ b/addons/contexts/src/manager/components/ToolBarMenuOptions.tsx @@ -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; diff --git a/addons/contexts/src/manager/ToolbarControl.tsx b/addons/contexts/src/manager/components/ToolbarControl.tsx similarity index 81% rename from addons/contexts/src/manager/ToolbarControl.tsx rename to addons/contexts/src/manager/components/ToolbarControl.tsx index cb4bfdf01e6d..68ea2bbca3d4 100644 --- a/addons/contexts/src/manager/ToolbarControl.tsx +++ b/addons/contexts/src/manager/components/ToolbarControl.tsx @@ -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< @@ -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 diff --git a/addons/contexts/src/manager/libs/useChannel.ts b/addons/contexts/src/manager/libs/useChannel.ts index a87b55ae260d..4eb1222f598d 100644 --- a/addons/contexts/src/manager/libs/useChannel.ts +++ b/addons/contexts/src/manager/libs/useChannel.ts @@ -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. diff --git a/addons/contexts/src/preview/ContextsPreviewAPI.ts b/addons/contexts/src/preview/ContextsPreviewAPI.ts new file mode 100644 index 000000000000..10453c1d9430 --- /dev/null +++ b/addons/contexts/src/preview/ContextsPreviewAPI.ts @@ -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, + }; +}); diff --git a/addons/contexts/src/preview/api.ts b/addons/contexts/src/preview/api.ts deleted file mode 100644 index 85fa0b6c6241..000000000000 --- a/addons/contexts/src/preview/api.ts +++ /dev/null @@ -1,52 +0,0 @@ -import addons from '@storybook/addons'; -import { FORCE_RE_RENDER, SET_CURRENT_STORY } from '@storybook/core-events'; -import { REBOOT_MANAGER, UPDATE_PREVIEW, UPDATE_MANAGER } from '../constants'; -import { getContextNodes, getPropsMap, getRendererFrom, singleton } from './libs'; -import { ContextNode, PropsMap } from '../types'; - -/** - * @Public - * A singleton for handling wrapper-manager side-effects - */ -export const addonContextsAPI = singleton(() => { - const channel = addons.getChannel(); - let memorizedNodes: null | ContextNode[] = null; - let selectionState = {}; - - // from manager - channel.on(SET_CURRENT_STORY, () => (memorizedNodes = null)); - channel.on(REBOOT_MANAGER, () => channel.emit(UPDATE_MANAGER, memorizedNodes, selectionState)); - channel.on(UPDATE_PREVIEW, state => (selectionState = Object.freeze(state))); - channel.on(UPDATE_PREVIEW, () => 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 (memorizedNodes === null) { - memorizedNodes = getContextNodes(...arg); - channel.emit(UPDATE_MANAGER, memorizedNodes); - } - return memorizedNodes; - }; - - /** - * (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); - - return { - // methods get called on Storybook event lifecycle - getContextNodes: getContextNodesWithSideEffects, - getSelectionState: () => selectionState, - getPropsMap, - - // methods for processing framework specific bindings - getRendererFrom, - updateReactiveSystem, - }; -}); diff --git a/addons/contexts/src/preview/frameworks/react.ts b/addons/contexts/src/preview/frameworks/react.ts index 8d6c0cd5d382..2984951dc614 100644 --- a/addons/contexts/src/preview/frameworks/react.ts +++ b/addons/contexts/src/preview/frameworks/react.ts @@ -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 = (contextNodes, propsMap, getStoryVNode) => { - const { getRendererFrom } = addonContextsAPI(); + const { getRendererFrom } = ContextsPreviewAPI(); return getRendererFrom(React.createElement)(contextNodes, propsMap, getStoryVNode); }; diff --git a/addons/contexts/src/preview/frameworks/vue.ts b/addons/contexts/src/preview/frameworks/vue.ts index 798e4457595e..9a62f38bef10 100644 --- a/addons/contexts/src/preview/frameworks/vue.ts +++ b/addons/contexts/src/preview/frameworks/vue.ts @@ -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 = (contextNodes, propsMap, getStoryVNode) => { - const { getRendererFrom, updateReactiveSystem } = addonContextsAPI(); + const { getRendererFrom, updateReactiveSystem } = ContextsPreviewAPI(); const reactiveProps = updateReactiveSystem(propsMap); return Vue.extend({ name: ID, diff --git a/addons/contexts/src/preview/index.ts b/addons/contexts/src/preview/index.ts deleted file mode 100644 index b35a1be5c8d8..000000000000 --- a/addons/contexts/src/preview/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { makeDecorator, StoryWrapper } from '@storybook/addons'; -import { addonContextsAPI } from './api'; -import { ID, PARAM } from '../constants'; -import { AddonSetting, AnyFunctionReturns, ContextNode, PropsMap } from '../types'; - -/** - * 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 = (...args: [ContextNode[], PropsMap, AnyFunctionReturns]) => T; -type CreateAddonDecorator = (render: Render) => (contexts: AddonSetting[]) => T; - -export const createAddonDecorator: CreateAddonDecorator = render => { - const wrapper: StoryWrapper = (getStory, context, settings: any) => { - const { getContextNodes, getSelectionState, getPropsMap } = addonContextsAPI(); - 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, - }); -}; diff --git a/addons/contexts/src/preview/libs/decorators.ts b/addons/contexts/src/preview/libs/decorators.ts index 8d6592160b6c..914e0a8eeacd 100644 --- a/addons/contexts/src/preview/libs/decorators.ts +++ b/addons/contexts/src/preview/libs/decorators.ts @@ -3,12 +3,12 @@ * the default is to memorize its the first argument; * @return the memorized version of a function. */ -type Memorize = ( +type memorize = ( fn: (...args: U) => T, resolver?: (...args: U) => unknown ) => (...args: U) => T; -export const memorize: Memorize = (fn, resolver) => { +export const memorize: memorize = (fn, resolver) => { const memo = new Map(); return (...arg) => { const key = resolver ? resolver(...arg) : arg[0]; @@ -21,6 +21,6 @@ export const memorize: Memorize = (fn, resolver) => { * the returned value is cached for resolving the subsequent calls. * @return the singleton version of a function. */ -type Singleton = (fn: (...args: U) => T) => (...args: U) => T; +type singleton = (fn: (...args: U) => T) => (...args: U) => T; -export const singleton: Singleton = fn => memorize(fn, () => 'singleton'); +export const singleton: singleton = fn => memorize(fn, () => 'singleton'); diff --git a/addons/contexts/src/preview/libs/getContextNodes.ts b/addons/contexts/src/preview/libs/getContextNodes.ts index 2bc4194a1124..16b88e3c4bf4 100644 --- a/addons/contexts/src/preview/libs/getContextNodes.ts +++ b/addons/contexts/src/preview/libs/getContextNodes.ts @@ -1,17 +1,18 @@ -import { AddonSetting, ContextNode, WrapperSettings } from '../../types'; +import { AddonSetting, ContextNode, WrapperSettings } from '../../shared/types'; /** * @private * Merge the top-level (global options) and the story-level (parameters) from a pair of setting; * @return the normalized definition for a contextual environment (i.e. a contextNode). */ -type GetMergedSettings = ( +type _getMergedSettings = ( topLevel: Partial, storyLevel: Partial ) => ContextNode; -export const _getMergedSettings: GetMergedSettings = (topLevel, storyLevel) => ({ - nodeId: topLevel.title || storyLevel.title || '', +export const _getMergedSettings: _getMergedSettings = (topLevel, storyLevel) => ({ + // strip out special characters reserved for serializing + nodeId: (topLevel.title || storyLevel.title || '').replace(/[,+]/g, ''), icon: topLevel.icon || storyLevel.icon || '', title: topLevel.title || storyLevel.title || '', components: topLevel.components || storyLevel.components || [], @@ -35,9 +36,9 @@ export const _getMergedSettings: GetMergedSettings = (topLevel, storyLevel) => ( * Pair up settings for merging normalizations to produce the contextual definitions (i.e. contextNodes); * it guarantee the adding order can be respected but not duplicated. */ -type GetContextNodes = (settings: WrapperSettings) => ContextNode[]; +type getContextNodes = (settings: WrapperSettings) => ContextNode[]; -export const getContextNodes: GetContextNodes = ({ options, parameters }) => { +export const getContextNodes: getContextNodes = ({ options, parameters }) => { const titles = Array() .concat(options, parameters) .filter(Boolean) diff --git a/addons/contexts/src/preview/libs/getPropsMap.test.ts b/addons/contexts/src/preview/libs/getPropsMap.test.ts index 7ee6f3dd3cde..c610eca94deb 100644 --- a/addons/contexts/src/preview/libs/getPropsMap.test.ts +++ b/addons/contexts/src/preview/libs/getPropsMap.test.ts @@ -1,5 +1,5 @@ import { _getPropsByParamName, getPropsMap } from './getPropsMap'; -import { OPT_OUT } from '../../constants'; +import { OPT_OUT } from '../../shared/constants'; describe('Test on behaviors from collecting the propsMap', () => { const someParams = [{ name: 'A', props: {} }, { name: 'B', props: {} }]; @@ -10,7 +10,7 @@ describe('Test on behaviors from collecting the propsMap', () => { }); it('should return "OPT_OUT" token when the context being opted out', () => { - const result = _getPropsByParamName(someParams, OPT_OUT); + const result = _getPropsByParamName(someParams, OPT_OUT, { cancelable: true }); expect(result).toBe(OPT_OUT); }); @@ -66,7 +66,7 @@ describe('Test on the integrity of the method to get the propMaps', () => { ]; const someSelectionState = { 'Some Context': 'A1', - 'Another Context': OPT_OUT, + 'Another Context': OPT_OUT, // an inconsistent but possible state being introduced via query param }; // exercise @@ -75,7 +75,7 @@ describe('Test on the integrity of the method to get the propMaps', () => { // assertion expect(result).toEqual({ 'Some Context': { a: 1 }, - 'Another Context': OPT_OUT, + 'Another Context': { b: 1 }, // not equal to `OPT_OUT` due to the context is not cancelable 'Other Contexts': { c: 1 }, }); }); diff --git a/addons/contexts/src/preview/libs/getPropsMap.ts b/addons/contexts/src/preview/libs/getPropsMap.ts index 8af688976694..d5e2007bbc01 100644 --- a/addons/contexts/src/preview/libs/getPropsMap.ts +++ b/addons/contexts/src/preview/libs/getPropsMap.ts @@ -1,19 +1,20 @@ -import { OPT_OUT } from '../../constants'; -import { ContextNode, GenericProp, PropsMap, SelectionState } from '../../types'; +import { OPT_OUT } from '../../shared/constants'; +import { ContextNode, GenericProp, PropsMap, SelectionState } from '../../shared/types'; /** * @private * Extract the activated props by name from a given contextual params. */ -type GetPropsByParamName = ( +type _getPropsByParamName = ( params: ContextNode['params'], - name?: string + name?: string, + options?: Partial ) => GenericProp | typeof OPT_OUT; -export const _getPropsByParamName: GetPropsByParamName = (params, name) => { +export const _getPropsByParamName: _getPropsByParamName = (params, name = '', options = {}) => { const { props = null } = // when opt-out context - (name === OPT_OUT && { props: OPT_OUT }) || + (options.cancelable && name === OPT_OUT && { props: OPT_OUT }) || // when menu option get selected (name && params.find(param => param.name === name)) || // when being initialized @@ -29,10 +30,10 @@ export const _getPropsByParamName: GetPropsByParamName = (params, name) => { * @nosideeffects * Collect the propsMap from Nodes based on a controlled state tracker. */ -type GetPropsMap = (contextNodes: ContextNode[], selectionState: SelectionState) => PropsMap; +type getPropsMap = (contextNodes: ContextNode[], selectionState: SelectionState) => PropsMap; -export const getPropsMap: GetPropsMap = (contextNodes, selectionState) => - contextNodes.reduce((agg, { nodeId, params }) => { - agg[nodeId] = _getPropsByParamName(params, selectionState[nodeId]); +export const getPropsMap: getPropsMap = (contextNodes, selectionState) => + contextNodes.reduce((agg, { nodeId, params, options }) => { + agg[nodeId] = _getPropsByParamName(params, selectionState[nodeId], options); return agg; }, Object()); diff --git a/addons/contexts/src/preview/libs/getRendererFrom.test.ts b/addons/contexts/src/preview/libs/getRendererFrom.test.ts index fd61c4a588fb..b5f7f399eff5 100644 --- a/addons/contexts/src/preview/libs/getRendererFrom.test.ts +++ b/addons/contexts/src/preview/libs/getRendererFrom.test.ts @@ -1,5 +1,5 @@ import { _getAggregatedWrap, getRendererFrom } from './getRendererFrom'; -import { OPT_OUT } from '../../constants'; +import { OPT_OUT } from '../../shared/constants'; // mocks const h = jest.fn(); @@ -23,7 +23,7 @@ describe('Test on aggregation of a single context', () => { it('should skip wrapping when props is marked as "OPT_OUT"', () => { const testedProps = OPT_OUT; - const testedOption = {}; + const testedOption = { cancelable: true }; spiedAggregator([fakeTag, fakeComponent], testedProps, testedOption)(); expect(h).toBeCalledTimes(0); }); diff --git a/addons/contexts/src/preview/libs/getRendererFrom.ts b/addons/contexts/src/preview/libs/getRendererFrom.ts index d864428ce9f9..c569a3e412fb 100644 --- a/addons/contexts/src/preview/libs/getRendererFrom.ts +++ b/addons/contexts/src/preview/libs/getRendererFrom.ts @@ -1,5 +1,11 @@ -import { OPT_OUT } from '../../constants'; -import { AddonOptions, AnyFunctionReturns, ContextNode, GenericProp, PropsMap } from '../../types'; +import { OPT_OUT } from '../../shared/constants'; +import { + AddonOptions, + AnyFunctionReturns, + ContextNode, + GenericProp, + PropsMap, +} from '../../shared/types'; /** * @private @@ -8,7 +14,7 @@ import { AddonOptions, AnyFunctionReturns, ContextNode, GenericProp, PropsMap } * * @param {function} h - the associated `createElement` vNode creator from the framework */ -type GetAggregatedWrap = ( +type _getAggregatedWrap = ( h: AnyFunctionReturns ) => ( components: ContextNode['components'], @@ -16,13 +22,17 @@ type GetAggregatedWrap = ( options: AddonOptions ) => AnyFunctionReturns; -export const _getAggregatedWrap: GetAggregatedWrap = h => (components, props, options) => vNode => { +export const _getAggregatedWrap: _getAggregatedWrap = h => ( + components, + props, + options +) => vNode => { const last = components.length - 1; const isSkipped = // when set to disable options.disable || // when opt-out context - props === OPT_OUT; + (options.cancelable && props === OPT_OUT); return isSkipped ? vNode @@ -41,11 +51,11 @@ export const _getAggregatedWrap: GetAggregatedWrap = h => (components, props, op * * @param {function} h - the associated `createElement` vNode creator from the framework */ -type GetRendererFrom = ( +type getRendererFrom = ( h: AnyFunctionReturns ) => (contextNodes: ContextNode[], propsMap: PropsMap, getStoryVNode: AnyFunctionReturns) => T; -export const getRendererFrom: GetRendererFrom = h => (contextNodes, propsMap, getStoryVNode) => +export const getRendererFrom: getRendererFrom = h => (contextNodes, propsMap, getStoryVNode) => contextNodes // map over contextual nodes to get the wrapping function .map(({ nodeId, components, options }) => diff --git a/addons/contexts/src/register.ts b/addons/contexts/src/register.ts index a3357a396992..69f785b10277 100644 --- a/addons/contexts/src/register.ts +++ b/addons/contexts/src/register.ts @@ -1,13 +1,13 @@ import { createElement } from 'react'; import addons, { types } from '@storybook/addons'; -import { AddonManager } from './manager/AddonManager'; -import { ID } from './constants'; +import { ContextsManager } from './manager/ContextsManager'; +import { ID } from './shared/constants'; addons.register(ID, api => addons.add(ID, { title: ID, type: types.TOOL, match: ({ viewMode }) => viewMode === 'story', - render: () => createElement(AddonManager, { channel: api.getChannel() }), + render: () => createElement(ContextsManager, { api }), }) ); diff --git a/addons/contexts/src/constants.ts b/addons/contexts/src/shared/constants.ts similarity index 87% rename from addons/contexts/src/constants.ts rename to addons/contexts/src/shared/constants.ts index 328a32433c0d..0e90045a06f6 100644 --- a/addons/contexts/src/constants.ts +++ b/addons/contexts/src/shared/constants.ts @@ -1,3 +1,5 @@ +export { FORCE_RE_RENDER, SET_CURRENT_STORY } from '@storybook/core-events'; + // configs export const ID = 'addon-contexts' as const; export const PARAM = 'contexts' as const; diff --git a/addons/contexts/src/shared/serializers.test.ts b/addons/contexts/src/shared/serializers.test.ts new file mode 100644 index 000000000000..d348233d6b33 --- /dev/null +++ b/addons/contexts/src/shared/serializers.test.ts @@ -0,0 +1,20 @@ +import { deserialize, serialize } from './serializers'; + +describe('Test on serializers', () => { + const someContextsQueryParam = 'CSS Themes=Forests,Languages=Fr'; + const someSelectionState = { + 'CSS Themes': 'Forests', + Languages: 'Fr', + }; + + it('Should serialize selection state into its string representation', () => { + expect(serialize(null)).toEqual(null); + expect(serialize(someSelectionState)).toEqual(someContextsQueryParam); + }); + + it('Should deserialize a string representation into the represented selection state', () => { + expect(deserialize('')).toEqual(null); + expect(deserialize('An invalid string=')).toEqual(null); + expect(deserialize(someContextsQueryParam)).toEqual(someSelectionState); + }); +}); diff --git a/addons/contexts/src/shared/serializers.ts b/addons/contexts/src/shared/serializers.ts new file mode 100644 index 000000000000..6b2343c36aea --- /dev/null +++ b/addons/contexts/src/shared/serializers.ts @@ -0,0 +1,29 @@ +import { SelectionState } from './types'; + +/** + * Serialize the selection state in its string representation. + */ +type serialize = (state: SelectionState) => string | null; + +export const serialize: serialize = state => + !state + ? null + : Object.entries(state) + .map(tuple => tuple.join('=')) + .join(','); + +/** + * Deserialize URL query param into the specified selection state. + */ +type deserialize = (param: string) => SelectionState | null; + +export const deserialize: deserialize = param => + !param + ? null + : param + .split(/,+/g) + .map(str => str.split(/=+/g)) + .reduce( + (acc, [nodeId, name]) => (nodeId && name ? { ...acc, [nodeId]: name } : acc), + null + ); diff --git a/addons/contexts/src/types.d.ts b/addons/contexts/src/shared/types.d.ts similarity index 96% rename from addons/contexts/src/types.d.ts rename to addons/contexts/src/shared/types.d.ts index a4933876c57e..47e5e32ec394 100644 --- a/addons/contexts/src/types.d.ts +++ b/addons/contexts/src/shared/types.d.ts @@ -1,4 +1,5 @@ import { ComponentProps, FunctionComponent } from 'react'; +export { API as ManagerAPI } from '@storybook/api'; import { Icons } from '@storybook/components'; // helpers