From d2c6ec6b6e9798b9f21820b998d37e5f74265050 Mon Sep 17 00:00:00 2001 From: "Leo Y. Li" Date: Sun, 7 Apr 2019 15:51:53 -0400 Subject: [PATCH] #14: refine architecture with singleton pattern --- addons/addon-contexts/src/@types/index.ts | 107 ++++-------------- addons/addon-contexts/src/@types/manager.ts | 49 ++++++++ addons/addon-contexts/src/@types/preview.ts | 39 +++++++ .../addon-contexts/src/components/ToolBar.tsx | 17 --- .../src/{libs => }/constants.ts | 3 +- .../src/containers/AddonManager.tsx | 35 ------ .../src/containers/ReactWrapper.tsx | 30 ----- addons/addon-contexts/src/index.ts | 19 +--- addons/addon-contexts/src/libs/ducks.ts | 41 ------- addons/addon-contexts/src/libs/helpers.ts | 107 ------------------ .../src/manager/AddonManager.tsx | 26 +++++ addons/addon-contexts/src/manager/ToolBar.tsx | 22 ++++ .../{components => manager}/ToolBarMenu.tsx | 0 .../ToolBarMenuOptions.tsx | 2 +- .../ToolbarControl.tsx} | 17 ++- .../hooks.ts => manager/libs/useChannel.ts} | 4 +- addons/addon-contexts/src/preview/api.ts | 33 ++++++ addons/addon-contexts/src/preview/index.ts | 1 + .../src/preview/libs/aggregators.ts | 40 +++++++ .../src/preview/libs/functionals.ts | 21 ++++ .../src/preview/libs/getContextNodes.ts | 46 ++++++++ .../src/preview/libs/getPropsMap.ts | 30 +++++ .../addon-contexts/src/preview/libs/index.ts | 3 + addons/addon-contexts/src/preview/react.ts | 12 ++ addons/addon-contexts/src/register.ts | 4 +- 25 files changed, 361 insertions(+), 347 deletions(-) create mode 100644 addons/addon-contexts/src/@types/manager.ts create mode 100644 addons/addon-contexts/src/@types/preview.ts delete mode 100644 addons/addon-contexts/src/components/ToolBar.tsx rename addons/addon-contexts/src/{libs => }/constants.ts (78%) delete mode 100644 addons/addon-contexts/src/containers/AddonManager.tsx delete mode 100644 addons/addon-contexts/src/containers/ReactWrapper.tsx delete mode 100644 addons/addon-contexts/src/libs/ducks.ts delete mode 100644 addons/addon-contexts/src/libs/helpers.ts create mode 100644 addons/addon-contexts/src/manager/AddonManager.tsx create mode 100644 addons/addon-contexts/src/manager/ToolBar.tsx rename addons/addon-contexts/src/{components => manager}/ToolBarMenu.tsx (100%) rename addons/addon-contexts/src/{components => manager}/ToolBarMenuOptions.tsx (90%) rename addons/addon-contexts/src/{containers/MenuController.tsx => manager/ToolbarControl.tsx} (73%) rename addons/addon-contexts/src/{libs/hooks.ts => manager/libs/useChannel.ts} (88%) create mode 100644 addons/addon-contexts/src/preview/api.ts create mode 100644 addons/addon-contexts/src/preview/index.ts create mode 100644 addons/addon-contexts/src/preview/libs/aggregators.ts create mode 100644 addons/addon-contexts/src/preview/libs/functionals.ts create mode 100644 addons/addon-contexts/src/preview/libs/getContextNodes.ts create mode 100644 addons/addon-contexts/src/preview/libs/getPropsMap.ts create mode 100644 addons/addon-contexts/src/preview/libs/index.ts create mode 100644 addons/addon-contexts/src/preview/react.ts diff --git a/addons/addon-contexts/src/@types/index.ts b/addons/addon-contexts/src/@types/index.ts index 0af97c176d4f..caa4ac720481 100644 --- a/addons/addon-contexts/src/@types/index.ts +++ b/addons/addon-contexts/src/@types/index.ts @@ -1,110 +1,45 @@ -declare type Channel = import('@storybook/channels').Channel; -declare type ComponentType = import('react').ComponentType; -declare type ComponentProps = import('react').ComponentProps; -declare type ReactNode = import('react').ReactNode; -declare type FC

= import('react').FunctionComponent

; +export * from './manager'; +export * from './preview'; -// auxiliary types -declare type FCNoChildren

= FC<{ children?: never } & P>; -declare type Omit = Pick>; -declare type AnyFunctionReturns = (...args: any[]) => T; -declare type GenericProps = { [key: string]: GenericProps } | null; -export declare type StringObject = { [key: string]: string }; +// helpers +export declare type AnyFunctionReturns = (...args: any[]) => T; +export declare type GenericObject = { [key: string]: GenericObject }; +export declare type GenericProp = GenericObject | null; export declare type StringTuple = [string, string]; +export declare type StringObject = { [key: string]: string }; +export declare type Omit = Pick>; -// config types +// shapes export declare type AddonOptions = { deep?: boolean; disable?: boolean; cancelable?: boolean; }; + export declare type AddonSetting = { icon?: string; title: string; - components?: ComponentType[]; + components?: unknown[]; params?: { name: string; - props: GenericProps; + props: GenericProp; default?: boolean; }[]; options?: AddonOptions; }; -export declare type WrapperSettings = { - options?: AddonSetting[]; - parameters?: AddonSetting[]; -}; + export declare type ContextNode = Required & { nodeId: string; }; -// duck types -export declare type UPDATE_PROPS_MAP = { - type: 'UPDATE_PROPS_MAP'; - payload: { - nodeId: string; - props: GenericProps | null; - }; +// wrappers +export declare type WrapperSettings = { + options: AddonSetting[] | undefined; + parameters?: AddonSetting[] | undefined; }; -export declare type PropsMapUpdaterType = ( - nodes: ContextNode[] -) => ([nodeId, name]: StringTuple) => UPDATE_PROPS_MAP; -// helper types -export declare type AggregateComponents = ( - h: AnyFunctionReturns -) => (...args: [ComponentType[], GenericProps, AddonOptions, number]) => AnyFunctionReturns; -export declare type AggregateContexts = ( - h: AnyFunctionReturns -) => (...args: [ContextNode[], Exclude]) => AnyFunctionReturns; -export declare type MergeSettings = ( - ...args: [Partial, Partial] -) => ContextNode; -export declare type GetContextNodes = (settings: WrapperSettings) => ContextNode[]; -export declare type Memorize = ( - fn: (...args: U) => T, - resolver: (...args: U) => unknown -) => (...args: U) => T; -export declare type UseChannel = ( - event: string, - eventHandler: AnyFunctionReturns, - input?: unknown[] -) => void; - -// Component types -export declare type Wrapper = (...args: [Function, unknown, WrapperSettings]) => unknown; -export declare type TAddonManager = FCNoChildren<{ - channel: Channel; -}>; -export declare type TAddonWrapper = FC<{ - channel: Channel; - nodes: ContextNode[]; - children: (ready: boolean) => ReactNode; -}>; -export declare type TMenuController = FCNoChildren< - Omit< - ContextNode & { - setSelect: (...args: StringTuple) => void; - }, - 'components' - > ->; -export declare type TToolBar = FCNoChildren<{ - setSelect: ComponentProps['setSelect']; - nodes: ContextNode[]; -}>; -export declare type TToolBarMenu = FCNoChildren<{ - icon: string; - title: string; - active: boolean; - expanded: boolean; - setExpanded: (state: boolean) => void; - optionsProps: ComponentProps; -}>; -export declare type TToolBarMenuOptions = FCNoChildren<{ - activeName: string; - list: string[]; - onSelectOption: (name: string) => () => void; -}>; +export declare type Wrapper = ( + ...args: [AnyFunctionReturns, unknown, WrapperSettings] +) => unknown; -// core types -export declare type WithContexts = (contexts: AddonSetting[]) => any; +export declare type WithContexts = (contexts: AddonSetting[]) => unknown; diff --git a/addons/addon-contexts/src/@types/manager.ts b/addons/addon-contexts/src/@types/manager.ts new file mode 100644 index 000000000000..e93f886bfa7b --- /dev/null +++ b/addons/addon-contexts/src/@types/manager.ts @@ -0,0 +1,49 @@ +import { Channel } from '@storybook/channels'; +import { FunctionComponent, ComponentProps } from 'react'; +import { AnyFunctionReturns, StringTuple, StringObject, Omit, ContextNode } from './index'; + +// helper +declare type FCNoChildren

= FunctionComponent<{ children?: never } & P>; + +// hooks +export declare type UseChannel = ( + event: string, + eventHandler: AnyFunctionReturns, + input?: unknown[] +) => void; + +// components +export type TAddonManager = FCNoChildren<{ + channel: Channel; +}>; + +export type TToolbarControl = FCNoChildren< + Omit< + ContextNode & { + selected: string; + setSelected: (...args: StringTuple) => void; + }, + 'components' + > +>; + +export type TToolBar = FCNoChildren<{ + nodes: ContextNode[]; + state: StringObject; + setSelected: ComponentProps['setSelected']; +}>; + +export type TToolBarMenu = FCNoChildren<{ + icon: string; + title: string; + active: boolean; + expanded: boolean; + setExpanded: (state: boolean) => void; + optionsProps: ComponentProps; +}>; + +export type TToolBarMenuOptions = FCNoChildren<{ + activeName: string; + list: string[]; + onSelectOption: (name: string) => () => void; +}>; diff --git a/addons/addon-contexts/src/@types/preview.ts b/addons/addon-contexts/src/@types/preview.ts new file mode 100644 index 000000000000..cb74aaf7918b --- /dev/null +++ b/addons/addon-contexts/src/@types/preview.ts @@ -0,0 +1,39 @@ +import { + AddonOptions, + AddonSetting, + AnyFunctionReturns, + ContextNode, + GenericObject, + GenericProp, + StringObject, + WrapperSettings, +} from './index'; + +export declare type Memorize = ( + fn: (...args: U) => T, + resolver?: (...args: U) => unknown +) => (...args: U) => T; + +export declare type Singleton = (fn: (...args: U) => T) => (...args: U) => T; + +export declare type AggregateComponents = ( + h: AnyFunctionReturns +) => ( + ...args: [ContextNode['components'], GenericProp, AddonOptions, number] +) => AnyFunctionReturns; + +export declare type AggregateContexts = ( + h: AnyFunctionReturns +) => (...args: [ContextNode[], GenericObject, AnyFunctionReturns]) => T; + +export declare type GetMergedSettings = ( + ...args: [Partial, Partial] +) => ContextNode; + +export declare type GetContextNodes = (settings: WrapperSettings) => ContextNode[]; + +export declare type GetPropsByParamName = ( + params: ContextNode['params'], + name?: string +) => GenericProp; +export declare type GetPropsMap = (nodes: ContextNode[], state: StringObject) => GenericObject; diff --git a/addons/addon-contexts/src/components/ToolBar.tsx b/addons/addon-contexts/src/components/ToolBar.tsx deleted file mode 100644 index 88cc9e993953..000000000000 --- a/addons/addon-contexts/src/components/ToolBar.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; -import { Separator } from '@storybook/components'; -import { MenuController } from '../containers/MenuController'; -import { TToolBar } from '../@types'; - -export const ToolBar: TToolBar = React.memo(({ nodes, setSelect }) => - nodes.length ? ( - <> - - {nodes.map(({ components, ...props }) => - props.params.length > 1 ? ( - - ) : null - )} - - ) : null -); diff --git a/addons/addon-contexts/src/libs/constants.ts b/addons/addon-contexts/src/constants.ts similarity index 78% rename from addons/addon-contexts/src/libs/constants.ts rename to addons/addon-contexts/src/constants.ts index ac43c4ba6803..3bfb5ded9c2f 100644 --- a/addons/addon-contexts/src/libs/constants.ts +++ b/addons/addon-contexts/src/constants.ts @@ -10,6 +10,5 @@ export const PARAM = 'contexts'; export const OPT_OUT = '__OPT_OUT__'; // events -export const INIT_WRAPPER = `${ID}/INIT_WRAPPER`; export const UPDATE_MANAGER = `${ID}/UPDATE_MANAGER`; -export const UPDATE_WRAPPER = `${ID}/UPDATE_WRAPPER`; +export const UPDATE_PREVIEW = `${ID}/UPDATE_PREVIEW`; diff --git a/addons/addon-contexts/src/containers/AddonManager.tsx b/addons/addon-contexts/src/containers/AddonManager.tsx deleted file mode 100644 index 4437730d5fd7..000000000000 --- a/addons/addon-contexts/src/containers/AddonManager.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; -import { useState, useCallback } from 'react'; -import { useChannel } from '../libs/hooks'; -import { ToolBar } from '../components/ToolBar'; -import { INIT_WRAPPER, UPDATE_MANAGER, UPDATE_WRAPPER } from '../libs/constants'; -import { TAddonManager } from '../@types'; - -/** - * Control addon states and addon-story interactions - */ -export const AddonManager: TAddonManager = ({ channel }) => { - const [nodes, setNodes] = useState([]); - const [source, record] = useState({}); - - // register channel-event handlers - useChannel( - UPDATE_MANAGER, - (newNodes) => { - setNodes(newNodes); - channel.emit(INIT_WRAPPER, source); - }, - [source] - ); - - // handler for caching and updating wrapper states - const setSelect = useCallback((nodeId, name) => { - channel.emit(UPDATE_WRAPPER, [nodeId, name]); - record((state) => ({ - ...state, - [nodeId]: name, - })); - }, []); - - return ; -}; diff --git a/addons/addon-contexts/src/containers/ReactWrapper.tsx b/addons/addon-contexts/src/containers/ReactWrapper.tsx deleted file mode 100644 index 4986c3242ea1..000000000000 --- a/addons/addon-contexts/src/containers/ReactWrapper.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { createElement as h, useEffect, useReducer, useState } from 'react'; -import { INIT_WRAPPER, UPDATE_MANAGER, UPDATE_WRAPPER } from '../libs/constants'; -import { propsMapsReducer, PropsMapUpdater } from '../libs/ducks'; -import { aggregateContexts } from '../libs/helpers'; -import { useChannel } from '../libs/hooks'; -import { TAddonWrapper, StringObject, StringTuple } from '../@types'; - -/** - * Wrap story under addon-injected contexts - */ -export const ReactWrapper: TAddonWrapper = ({ channel, nodes, children }) => { - const [propsMap, dispatch] = useReducer(propsMapsReducer, {}); - const [ready, setReady] = useState(false); - - // register channel-event handlers - const updatePropsMap = (tuple: StringTuple) => dispatch(PropsMapUpdater(nodes)(tuple)); - useChannel(UPDATE_WRAPPER, updatePropsMap); - useChannel(INIT_WRAPPER, (source: StringObject) => { - nodes - .filter(({ nodeId }) => !(nodeId in source)) - .forEach(({ nodeId }) => updatePropsMap([nodeId, ''])); - Object.entries(source).forEach(updatePropsMap); - setReady(true); - }); - - // push state to the manager (and wait to be initialized) - useEffect(() => channel.emit(UPDATE_MANAGER, nodes), []); - - return aggregateContexts(h)(nodes, propsMap)(children(ready)); -}; diff --git a/addons/addon-contexts/src/index.ts b/addons/addon-contexts/src/index.ts index bc96b8510073..6f72e7a45483 100644 --- a/addons/addon-contexts/src/index.ts +++ b/addons/addon-contexts/src/index.ts @@ -1,18 +1,7 @@ -import { createElement as h } from 'react'; -import addons, { makeDecorator } from '@storybook/addons'; -import { ReactWrapper } from './containers/ReactWrapper'; -import { ID, PARAM } from './libs/constants'; -import { getContextNodes } from './libs/helpers'; -import { WithContexts, Wrapper } from './@types'; - -const wrapper: Wrapper = (getStory, context, settings) => { - const nodes = getContextNodes(settings); - return h(ReactWrapper, { - nodes, - channel: addons.getChannel(), - children: (ready: boolean) => () => (ready ? getStory(context) : h('div')), - }); -}; +import { makeDecorator } from '@storybook/addons'; +import { ID, PARAM } from './constants'; +import { wrapper } from './preview'; +import { WithContexts } from './@types'; export const withContexts: WithContexts = makeDecorator({ name: ID, diff --git a/addons/addon-contexts/src/libs/ducks.ts b/addons/addon-contexts/src/libs/ducks.ts deleted file mode 100644 index 27becb3daf4f..000000000000 --- a/addons/addon-contexts/src/libs/ducks.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * For more information about ducks: - * @see https://github.com/erikras/ducks-modular-redux - */ -import { OPT_OUT } from './constants'; -import { UPDATE_PROPS_MAP, PropsMapUpdaterType } from '../@types'; - -// reducers -export const propsMapsReducer = (state: any, { type, payload }: UPDATE_PROPS_MAP) => { - switch (type) { - case 'UPDATE_PROPS_MAP': { - return { - ...state, - [payload.nodeId]: payload.props, - }; - } - default: { - return null; - } - } -}; - -// actions -export const PropsMapUpdater: PropsMapUpdaterType = (nodes) => ([nodeId, name]) => { - const { params = [] } = nodes.find((node) => node.nodeId === nodeId) || {}; - const { props = null } = - // when opt-out context - (name === OPT_OUT && {}) || - // when menu option get selected - (name && params.find((param) => param.name === name)) || - // when being initialized - params.find((param) => !!param.default) || - // fallback to the first - params[0] || - // fallback for destructuring - {}; - return { - type: 'UPDATE_PROPS_MAP', - payload: { nodeId, props }, - }; -}; diff --git a/addons/addon-contexts/src/libs/helpers.ts b/addons/addon-contexts/src/libs/helpers.ts deleted file mode 100644 index 809c771ad5d5..000000000000 --- a/addons/addon-contexts/src/libs/helpers.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { - Memorize, - GetContextNodes, - MergeSettings, - AggregateComponents, - AggregateContexts, -} from '../@types'; - -/** - * @private - * Memorizes the calculated result of a function by an ES6 Map; - * @return the memorized version of a function. - */ -export const _memorize: Memorize = (fn, resolver) => { - const memo = new Map(); - return (...arg) => { - const key = (resolver && resolver(...arg)) || arg[0]; - return memo.get(key) || memo.set(key, fn(...arg)).get(key); - }; -}; - -/** - * @private - * Merges the global (options) and the local (parameters) from a pair of setting; - * @return the normalized definition for a contextual environment (-> node). - */ -export const _mergeSettings: MergeSettings = ( - { icon = '', title = '', components = [], params = [], options = {} }, - { params: storyParams = [], options: storyOptions = {} } -) => ({ - nodeId: title, - icon: icon, - title: title, - components: components, - params: !!(params.length || storyParams.length) - ? params.concat(storyParams) - : [{ name: '', props: {} }], - options: Object.assign( - { - deep: false, - disable: false, - cancelable: false, - }, - options, - storyOptions - ), -}); - -/** - * @private - * pairs up settings for merging normalizations to produce the contextual definitions (-> nodes); - * it guarantee the adding order can be respected but not duplicated. - */ -export const _getContextNodes: GetContextNodes = ({ options, parameters }) => { - const titles = Array() - .concat(options, parameters) - .map(({ title } = {}) => title); - return [...new Set(titles)] - .filter(Boolean) - .map((title) => - _mergeSettings( - (options && options.find((option) => option.title === title)) || {}, - (parameters && parameters.find((param) => param.title === title)) || {} - ) - ); -}; - -/** - * @private - * Aggregates components with activated props in a descending order, - * based on the given options in the contextual environment setup. - - * @param {function} h - the associated `createElement` vNode creator from the framework - */ -export const _aggregateComponents: AggregateComponents = (h) => ( - components, - props, - options, - last -) => (next) => - // when set to disable - options.disable || - // when opt-out context - props === null || - // when get uninitialized props but set to non-cancelable (i.e. props is required) - (props === undefined && !options.cancelable) - ? next() - : components - .reverse() - .reduce( - (acc, C, index) => h(C, options.deep || index === last ? props : null, acc), - next() - ); - -/** - * Aggregate aggregated-components among all contextual nodes in a descending order. - * - * @param {function} h - the associated `createElement` vNode creator from the framework - */ -export const aggregateContexts: AggregateContexts = (h) => (nodes, propsMap) => (next) => - nodes - .map(({ nodeId, components = [], options = {} }) => - _aggregateComponents(h)(components, propsMap[nodeId], options, components.length - 1) - ) - .reduce((acc, agg) => agg(() => acc), next()); - -export const getContextNodes = _memorize(_getContextNodes, ({ parameters }) => parameters); diff --git a/addons/addon-contexts/src/manager/AddonManager.tsx b/addons/addon-contexts/src/manager/AddonManager.tsx new file mode 100644 index 000000000000..ea02d9ad6863 --- /dev/null +++ b/addons/addon-contexts/src/manager/AddonManager.tsx @@ -0,0 +1,26 @@ +import React, { useEffect } from 'react'; +import { useState, useCallback } from 'react'; +import { useChannel } from './libs/useChannel'; +import { ToolBar } from './ToolBar'; +import { UPDATE_MANAGER, UPDATE_PREVIEW } from '../constants'; +import { TAddonManager } from '../@types'; + +/** + * Control addon states and addon-story interactions + */ +export const AddonManager: TAddonManager = ({ channel }) => { + const [nodes, setNodes] = useState([]); + const [state, setState] = useState({}); + const setSelected = useCallback( + (nodeId, name) => setState((state) => ({ ...state, [nodeId]: name })), + [] + ); + + // from preview + useChannel(UPDATE_MANAGER, (newNodes) => setNodes(newNodes), [state]); + + // to preview + useEffect(() => channel.emit(UPDATE_PREVIEW, state), [state]); + + return ; +}; diff --git a/addons/addon-contexts/src/manager/ToolBar.tsx b/addons/addon-contexts/src/manager/ToolBar.tsx new file mode 100644 index 000000000000..a6213be54e5d --- /dev/null +++ b/addons/addon-contexts/src/manager/ToolBar.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { Separator } from '@storybook/components'; +import { ToolbarControl } from './ToolbarControl'; +import { TToolBar } from '../@types'; + +export const ToolBar: TToolBar = React.memo(({ nodes, state, setSelected }) => + nodes.length ? ( + <> + + {nodes.map(({ components, ...forwardProps }) => + forwardProps.params.length > 1 ? ( + + ) : null + )} + + ) : null +); diff --git a/addons/addon-contexts/src/components/ToolBarMenu.tsx b/addons/addon-contexts/src/manager/ToolBarMenu.tsx similarity index 100% rename from addons/addon-contexts/src/components/ToolBarMenu.tsx rename to addons/addon-contexts/src/manager/ToolBarMenu.tsx diff --git a/addons/addon-contexts/src/components/ToolBarMenuOptions.tsx b/addons/addon-contexts/src/manager/ToolBarMenuOptions.tsx similarity index 90% rename from addons/addon-contexts/src/components/ToolBarMenuOptions.tsx rename to addons/addon-contexts/src/manager/ToolBarMenuOptions.tsx index ffe448716b61..7c563e3a6fb7 100644 --- a/addons/addon-contexts/src/components/ToolBarMenuOptions.tsx +++ b/addons/addon-contexts/src/manager/ToolBarMenuOptions.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { TooltipLinkList } from '@storybook/components'; -import { OPT_OUT } from '../libs/constants'; +import { OPT_OUT } from '../constants'; import { TToolBarMenuOptions } from '../@types'; export const ToolBarMenuOptions: TToolBarMenuOptions = ({ activeName, list, onSelectOption }) => ( diff --git a/addons/addon-contexts/src/containers/MenuController.tsx b/addons/addon-contexts/src/manager/ToolbarControl.tsx similarity index 73% rename from addons/addon-contexts/src/containers/MenuController.tsx rename to addons/addon-contexts/src/manager/ToolbarControl.tsx index 878dab54c6dd..750c15ba6437 100644 --- a/addons/addon-contexts/src/containers/MenuController.tsx +++ b/addons/addon-contexts/src/manager/ToolbarControl.tsx @@ -1,20 +1,19 @@ import React from 'react'; -import { ToolBarMenu } from '../components/ToolBarMenu'; -import { OPT_OUT } from '../libs/constants'; -import { TMenuController } from '../@types'; +import { ToolBarMenu } from './ToolBarMenu'; +import { OPT_OUT } from '../constants'; +import { TToolbarControl } from '../@types'; -export const MenuController: TMenuController = ({ +export const ToolbarControl: TToolbarControl = ({ nodeId, icon, title, params, options, - setSelect, + selected, + setSelected, }) => { const [expanded, setExpanded] = React.useState(false); - const [selected, setSelected] = React.useState(''); const paramNames = params.map(({ name }) => name); - const list = options.cancelable === false ? paramNames : [OPT_OUT, ...paramNames]; const activeName = // validate the selected name (paramNames.concat(OPT_OUT).includes(selected) && selected) || @@ -22,6 +21,7 @@ export const MenuController: TMenuController = ({ (params.find((param) => !!param.default) || { name: null }).name || // fallback to the first params[0].name; + const list = options.cancelable === false ? paramNames : [OPT_OUT, ...paramNames]; const props = { icon, title, @@ -33,8 +33,7 @@ export const MenuController: TMenuController = ({ list, onSelectOption: (name: string) => () => { setExpanded(false); - setSelected(name); - setSelect(nodeId, name); + setSelected(nodeId, name); }, }, }; diff --git a/addons/addon-contexts/src/libs/hooks.ts b/addons/addon-contexts/src/manager/libs/useChannel.ts similarity index 88% rename from addons/addon-contexts/src/libs/hooks.ts rename to addons/addon-contexts/src/manager/libs/useChannel.ts index 64dbb1fbf56b..2cd4b430243d 100644 --- a/addons/addon-contexts/src/libs/hooks.ts +++ b/addons/addon-contexts/src/manager/libs/useChannel.ts @@ -1,6 +1,6 @@ -import { useEffect } from 'react'; import addons from '@storybook/addons'; -import { UseChannel } from '../@types'; +import { useEffect } from 'react'; +import { UseChannel } from '../../@types'; export const useChannel: UseChannel = (event, eventHandler, inputs = []) => useEffect(() => { diff --git a/addons/addon-contexts/src/preview/api.ts b/addons/addon-contexts/src/preview/api.ts new file mode 100644 index 000000000000..333ebc40f03a --- /dev/null +++ b/addons/addon-contexts/src/preview/api.ts @@ -0,0 +1,33 @@ +import addons from '@storybook/addons'; +import { FORCE_RE_RENDER } from '@storybook/core-events'; +import { UPDATE_PREVIEW, UPDATE_MANAGER } from '../constants'; +import { getContextNodes, getPropsMap, aggregateContexts } from './libs'; +import { singleton } from './libs/functionals'; +import { GetContextNodes } from '../@types'; + +/** + * @Public + * A singleton for handling wrapper-manager side-effects + */ +export const addonContextsAPI = singleton(() => { + let selectionState = {}; + const channel = addons.getChannel(); + + // from manager + channel.on(UPDATE_PREVIEW, (state) => (selectionState = Object.freeze(state))); + channel.on(UPDATE_PREVIEW, () => channel.emit(FORCE_RE_RENDER)); + + // to manager + const getContextNodesWithSideEffects: GetContextNodes = (...arg) => { + const nodes = getContextNodes(...arg); + channel.emit(UPDATE_MANAGER, nodes); + return nodes; + }; + + return { + aggregate: aggregateContexts, + getSelectionState: () => selectionState, + getContextNodes: getContextNodesWithSideEffects, + getPropsMap, + }; +}); diff --git a/addons/addon-contexts/src/preview/index.ts b/addons/addon-contexts/src/preview/index.ts new file mode 100644 index 000000000000..9b80e982f510 --- /dev/null +++ b/addons/addon-contexts/src/preview/index.ts @@ -0,0 +1 @@ +export { reactWrapper as wrapper } from './react'; diff --git a/addons/addon-contexts/src/preview/libs/aggregators.ts b/addons/addon-contexts/src/preview/libs/aggregators.ts new file mode 100644 index 000000000000..c4007b7ba3d1 --- /dev/null +++ b/addons/addon-contexts/src/preview/libs/aggregators.ts @@ -0,0 +1,40 @@ +import { AggregateComponents, AggregateContexts } from '../../@types'; + +/** + * @private + * Aggregates components with activated props in a descending order, + * based on the given options in the contextual environment setup. + + * @param {function} h - the associated `createElement` vNode creator from the framework + */ +export const _aggregateComponents: AggregateComponents = (h) => ( + components, + props, + options, + last +) => (next) => + // when set to disable + options.disable || + // when opt-out context + props === null || + // when get uninitialized props but set to non-cancelable (i.e. props is required) + (props === undefined && !options.cancelable) + ? next() + : components + .reverse() + .reduce( + (acc, C, index) => h(C, options.deep || index === last ? props : null, acc), + next() + ); + +/** + * Aggregate aggregated-components among all contextual nodes in a descending order. + * + * @param {function} h - the associated `createElement` vNode creator from the framework + */ +export const aggregateContexts: AggregateContexts = (h) => (nodes, propsMap, next) => + nodes + .map(({ nodeId, components = [], options = {} }) => + _aggregateComponents(h)(components, propsMap[nodeId], options, components.length - 1) + ) + .reduce((acc, agg) => agg(() => acc), next()); diff --git a/addons/addon-contexts/src/preview/libs/functionals.ts b/addons/addon-contexts/src/preview/libs/functionals.ts new file mode 100644 index 000000000000..ea167d29088e --- /dev/null +++ b/addons/addon-contexts/src/preview/libs/functionals.ts @@ -0,0 +1,21 @@ +import { Memorize, Singleton } from '../../@types'; + +/** + * Memorizes the calculated result of a function by an ES6 Map; + * the default is to memorize its the first argument; + * @return the memorized version of a function. + */ +export const memorize: Memorize = function(fn, resolver) { + const memo = new Map(); + return (...arg) => { + const key = arguments.length === 1 ? arg[0] : resolver && resolver(...arg); + return memo.get(key) || memo.set(key, fn(...arg)).get(key); + }; +}; + +/** + * Enforce a given function can only be executed once; + * the returned value is cached for resolving the subsequent calls. + * @return the singleton version of a function. + */ +export const singleton: Singleton = (fn) => memorize(fn, () => 'singleton'); diff --git a/addons/addon-contexts/src/preview/libs/getContextNodes.ts b/addons/addon-contexts/src/preview/libs/getContextNodes.ts new file mode 100644 index 000000000000..c826041992a1 --- /dev/null +++ b/addons/addon-contexts/src/preview/libs/getContextNodes.ts @@ -0,0 +1,46 @@ +import { GetContextNodes, GetMergedSettings } from '../../@types'; + +/** + * @private + * Merges the global (options) and the local (parameters) from a pair of setting; + * @return the normalized definition for a contextual environment (-> node). + */ +export const _getMergedSettings: GetMergedSettings = ( + { icon, title, components = [], params = [], options = {} }, + { params: storyParams = [], options: storyOptions = {}, ...story } +) => ({ + nodeId: title || story.title || '', + icon: icon || story.icon || '', + title: title || story.title || '', + components: components, + params: !!(params.length || storyParams.length) + ? params.concat(storyParams) + : [{ name: '', props: {} }], + options: Object.assign( + { + deep: false, + disable: false, + cancelable: false, + }, + options, + storyOptions + ), +}); + +/** + * pairs up settings for merging normalizations to produce the contextual definitions (-> nodes); + * it guarantee the adding order can be respected but not duplicated. + */ +export const getContextNodes: GetContextNodes = ({ options, parameters }) => { + const titles = Array() + .concat(options, parameters) + .map(({ title } = {}) => title); + return Array.from(new Set(titles)) + .filter(Boolean) + .map((title) => + _getMergedSettings( + (options && options.find((option) => option.title === title)) || {}, + (parameters && parameters.find((param) => param.title === title)) || {} + ) + ); +}; diff --git a/addons/addon-contexts/src/preview/libs/getPropsMap.ts b/addons/addon-contexts/src/preview/libs/getPropsMap.ts new file mode 100644 index 000000000000..159f5f32a305 --- /dev/null +++ b/addons/addon-contexts/src/preview/libs/getPropsMap.ts @@ -0,0 +1,30 @@ +import { OPT_OUT } from '../../constants'; +import { ContextNode, GetPropsByParamName, GetPropsMap } from '../../@types'; + +/** + * @private + * get the activated props by name from a given contextual params. + */ +export const _getPropsByParamName: GetPropsByParamName = (params = [], name) => { + const { props = null } = + // when opt-out context + (name === OPT_OUT && {}) || + // when menu option get selected + (name && params.find((param) => param.name === name)) || + // when being initialized + params.find((param) => !!param.default) || + // fallback to the first + params[0] || + // fallback for destructuring + {}; + return props; +}; + +/** + * construct propsMap from Nodes based on a controlled state tracker. + */ +export const getPropsMap: GetPropsMap = (nodes: ContextNode[], state) => + nodes.reduce((agg, { nodeId, params }) => { + agg[nodeId] = _getPropsByParamName(params, state[nodeId]); + return agg; + }, Object()); diff --git a/addons/addon-contexts/src/preview/libs/index.ts b/addons/addon-contexts/src/preview/libs/index.ts new file mode 100644 index 000000000000..515a411aecb8 --- /dev/null +++ b/addons/addon-contexts/src/preview/libs/index.ts @@ -0,0 +1,3 @@ +export { aggregateContexts } from './aggregators'; +export { getContextNodes } from './getContextNodes'; +export { getPropsMap } from './getPropsMap'; diff --git a/addons/addon-contexts/src/preview/react.ts b/addons/addon-contexts/src/preview/react.ts new file mode 100644 index 000000000000..153ab975515f --- /dev/null +++ b/addons/addon-contexts/src/preview/react.ts @@ -0,0 +1,12 @@ +import { createElement as h } from 'react'; +import { addonContextsAPI } from './api'; +import { Wrapper } from '../@types'; + +export const reactWrapper: Wrapper = (getStory, context, settings) => { + const { aggregate, getContextNodes, getSelectionState, getPropsMap } = addonContextsAPI(); + const nodes = getContextNodes(settings); + const state = getSelectionState(); + const propsMap = getPropsMap(nodes, state); + const loadStory = () => getStory(context); + return aggregate(h)(nodes, propsMap, loadStory); +}; diff --git a/addons/addon-contexts/src/register.ts b/addons/addon-contexts/src/register.ts index 6b0135ed45cd..9fb6e4b83952 100644 --- a/addons/addon-contexts/src/register.ts +++ b/addons/addon-contexts/src/register.ts @@ -1,7 +1,7 @@ import { createElement as h } from 'react'; import addons, { types } from '@storybook/addons'; -import { AddonManager } from './containers/AddonManager'; -import { ID } from './libs/constants'; +import { AddonManager } from './manager/AddonManager'; +import { ID } from './constants'; addons.register(ID, (api) => addons.add(ID, {