Skip to content

Commit

Permalink
Shared useaddonstate (#9079)
Browse files Browse the repository at this point in the history
Shared useaddonstate

Co-authored-by: Norbert de Langen <[email protected]>
  • Loading branch information
ndelangen committed Dec 20, 2019
2 parents 75d4d6b + 05492f1 commit aeade42
Show file tree
Hide file tree
Showing 11 changed files with 225 additions and 43 deletions.
22 changes: 21 additions & 1 deletion examples/dev-kits/main.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,23 @@
module.exports = {
stories: [`${__dirname}/stories/*.*`],
stories: ['./stories/*.*'],
webpack: async (config, { configType }) => ({
...config,
module: {
...config.module,
rules: [
...config.module.rules,
{
test: /\.(ts|tsx)$/,
loader: require.resolve('babel-loader'),
options: {
presets: [['react-app', { flow: false, typescript: true }]],
},
},
],
},
resolve: {
...config.resolve,
extensions: [...(config.resolve.extensions || []), '.ts', '.tsx'],
},
}),
};
42 changes: 38 additions & 4 deletions examples/dev-kits/manager.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,46 @@
import '@storybook/addon-roundtrip/register';
import '@storybook/addon-parameter/register';
import '@storybook/addon-preview-wrapper/register';

import React from 'react';
import { PropTypes } from 'prop-types';
import { Button } from '@storybook/react/demo';
import { addons } from '@storybook/addons';
import { useAddonState } from '@storybook/api';
import { themes } from '@storybook/theming';
import { AddonPanel } from '@storybook/components';

addons.setConfig({
theme: themes.dark,
panelPosition: 'bottom',
selectedPanel: 'storybook/roundtrip',
});

const StatePanel = ({ active, key }) => {
const [managerState, setManagerState] = useAddonState('manager', 10);
const [previewState, setPreviewState] = useAddonState('preview');
return (
<AddonPanel key={key} active={active}>
<div>
Manager counter: {managerState}
<br />
<Button onClick={() => setManagerState(managerState - 1)}>decrement</Button>
<Button onClick={() => setManagerState(managerState + 1)}>increment</Button>
</div>
<br />
<div>
Preview counter: {previewState}
<br />
<Button onClick={() => previewState && setPreviewState(previewState - 1)}>decrement</Button>
<Button onClick={() => previewState && setPreviewState(previewState + 1)}>increment</Button>
</div>
</AddonPanel>
);
};

StatePanel.propTypes = {
active: PropTypes.bool.isRequired,
key: PropTypes.string.isRequired,
};

addons.addPanel('useAddonState', {
id: 'useAddonState',
title: 'useAddonState',
render: StatePanel,
});
7 changes: 5 additions & 2 deletions examples/dev-kits/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@
"version": "5.3.0-rc.0",
"private": true,
"scripts": {
"build-storybook": "build-storybook -c ./",
"storybook": "start-storybook -p 9011 -c ./"
"build-storybook": "cross-env STORYBOOK_DISPLAY_WARNING=true DISPLAY_WARNING=true build-storybook -c ./",
"debug": "cross-env NODE_OPTIONS=--inspect-brk STORYBOOK_DISPLAY_WARNING=true DISPLAY_WARNING=true start-storybook -p 9011 -c ./ --no-dll",
"storybook": "cross-env STORYBOOK_DISPLAY_WARNING=true DISPLAY_WARNING=true start-storybook -p 9011 -c ./ --no-dll"
},
"devDependencies": {
"@storybook/addon-decorator": "5.3.0-rc.0",
"@storybook/addon-parameter": "5.3.0-rc.0",
"@storybook/addon-preview-wrapper": "5.3.0-rc.0",
"@storybook/addon-roundtrip": "5.3.0-rc.0",
"@storybook/addons": "5.3.0-rc.0",
"@storybook/api": "5.3.0-rc.0",
"@storybook/client-api": "5.3.0-rc.0",
"@storybook/components": "5.3.0-rc.0",
"@storybook/core-events": "5.3.0-rc.0",
"@storybook/node-logger": "5.3.0-rc.0",
Expand Down
33 changes: 33 additions & 0 deletions examples/dev-kits/stories/addon-useaddonstate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import React from 'react';
import { Button } from '@storybook/react/demo';
import { useAddonState } from '@storybook/client-api';

export default {
title: 'addons|useAddonState',
};

export const managerDefault = () => {
const [state, setState] = useAddonState<number>('manager');

return (
<div style={{ color: 'white' }}>
Manager counter: {state}
<br />
<Button onClick={() => setState(state - 1)}>decrement</Button>
<Button onClick={() => setState(state + 1)}>increment</Button>
</div>
);
};

export const previewDefault = () => {
const [state, setState] = useAddonState<number>('preview', 50);

return (
<div style={{ color: 'white' }}>
Preview counter: {state}
<br />
<Button onClick={() => setState(state - 1)}>decrement</Button>
<Button onClick={() => setState(state + 1)}>increment</Button>
</div>
);
};
3 changes: 2 additions & 1 deletion lib/addons/src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,7 @@ export interface EventMap {
/* Accepts a map of Storybook channel event listeners, returns an emit function */
export function useChannel(eventMap: EventMap, deps: any[] = []) {
const channel = addons.getChannel();

useEffect(() => {
Object.entries(eventMap).forEach(([type, listener]) => channel.on(type, listener));
return () => {
Expand All @@ -387,7 +388,7 @@ export function useChannel(eventMap: EventMap, deps: any[] = []) {
};
}, [...Object.keys(eventMap), ...deps]);

return channel.emit.bind(channel);
return useCallback(channel.emit.bind(channel), [channel]);
}

/* Returns current story context */
Expand Down
107 changes: 74 additions & 33 deletions lib/api/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import React, { ReactElement, Component, useContext, useEffect, useRef } from 'react';
import React, { ReactElement, Component, useContext, useEffect, useMemo } from 'react';
import memoize from 'memoizerific';
// @ts-ignore shallow-equal is not in DefinitelyTyped
import shallowEqualObjects from 'shallow-equal/objects';

import Events from '@storybook/core-events';
import {
STORIES_CONFIGURED,
STORY_CHANGED,
SET_STORIES,
SELECT_STORY,
ADDON_STATE_CHANGED,
ADDON_STATE_SET,
} from '@storybook/core-events';
import { RenderData as RouterData } from '@storybook/router';
import { Listener } from '@storybook/channels';
import initProviderApi, { SubAPI as ProviderAPI, Provider } from './init-provider-api';
Expand Down Expand Up @@ -38,8 +45,6 @@ export { Options as StoreOptions, Listener as ChannelListener };

const ManagerContext = createContext({ api: undefined, state: getInitialState({}) });

const { STORY_CHANGED, SET_STORIES, SELECT_STORY } = Events;

export type Module = StoreData &
RouterData &
ProviderData & {
Expand Down Expand Up @@ -186,7 +191,6 @@ class ManagerProvider extends Component<Props, State> {
api.selectStory(kind, story, rest);
}
);

this.state = state;
this.api = api;
}
Expand Down Expand Up @@ -310,40 +314,14 @@ function orDefault<S>(fromStore: S, defaultState: S): S {
return fromStore;
}

type StateMerger<S> = (input: S) => S;

export function useAddonState<S>(addonId: string, defaultState?: S) {
const api = useStorybookApi();
const ref = useRef<{ [k: string]: boolean }>({});

const existingState = api.getAddonState<S>(addonId);
const state = orDefault<S>(existingState, defaultState);

const setState = (newStateOrMerger: S | StateMerger<S>, options?: Options) => {
return api.setAddonState<S>(addonId, newStateOrMerger, options);
};

if (typeof existingState === 'undefined' && typeof state !== 'undefined') {
if (!ref.current[addonId]) {
api.setAddonState<S>(addonId, state);
ref.current[addonId] = true;
}
}

return [state, setState] as [
S,
(newStateOrMerger: S | StateMerger<S>, options?: Options) => Promise<S>
];
}

export const useChannel = (eventMap: EventMap) => {
export const useChannel = (eventMap: EventMap, deps: any[] = []) => {
const api = useStorybookApi();
useEffect(() => {
Object.entries(eventMap).forEach(([type, listener]) => api.on(type, listener));
return () => {
Object.entries(eventMap).forEach(([type, listener]) => api.off(type, listener));
};
});
}, deps);

return api.emit;
};
Expand All @@ -354,3 +332,66 @@ export function useParameter<S>(parameterKey: string, defaultValue?: S) {
const result = api.getCurrentParameter<S>(parameterKey);
return orDefault<S>(result, defaultValue);
}

type StateMerger<S> = (input: S) => S;
// chache for taking care of HMR
const addonStateCache: {
[key: string]: any;
} = {};

// shared state
export function useAddonState<S>(addonId: string, defaultState?: S) {
const api = useStorybookApi();
const existingState = api.getAddonState<S>(addonId);
const state = orDefault<S>(
existingState,
addonStateCache[addonId] ? addonStateCache[addonId] : defaultState
);
const setState = (s: S | StateMerger<S>, options?: Options) => {
// set only after the stories are loaded
if (addonStateCache[addonId]) {
addonStateCache[addonId] = s;
}
api.setAddonState<S>(addonId, s, options);
};
const allListeners = useMemo(() => {
const stateChangeHandlers = {
[`${ADDON_STATE_CHANGED}-client-${addonId}`]: (s: S) => setState(s),
[`${ADDON_STATE_SET}-client-${addonId}`]: (s: S) => setState(s),
};
const stateInitializationHandlers = {
[STORIES_CONFIGURED]: () => {
if (addonStateCache[addonId]) {
// this happens when HMR
setState(addonStateCache[addonId]);
api.emit(`${ADDON_STATE_SET}-manager-${addonId}`, addonStateCache[addonId]);
} else if (defaultState !== undefined) {
// if not HMR, yet the defaults are form the manager
setState(defaultState);
// initialize addonStateCache after first load, so its available for subsequent HMR
addonStateCache[addonId] = defaultState;
api.emit(`${ADDON_STATE_SET}-manager-${addonId}`, defaultState);
}
},
[STORY_CHANGED]: () => {
if (api.getAddonState(addonId) !== undefined) {
api.emit(`${ADDON_STATE_SET}-manager-${addonId}`, api.getAddonState(addonId));
}
},
};

return {
...stateChangeHandlers,
...stateInitializationHandlers,
};
}, [addonId]);

const emit = useChannel(allListeners);
return [
state,
(newStateOrMerger: S | StateMerger<S>, options?: Options) => {
setState(newStateOrMerger, options);
emit(`${ADDON_STATE_CHANGED}-manager-${addonId}`, newStateOrMerger);
},
] as [S, (newStateOrMerger: S | StateMerger<S>, options?: Options) => void];
}
2 changes: 1 addition & 1 deletion lib/channel-postmessage/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export class PostmsgTransport {
*/
send(event: ChannelEvent, options?: any): Promise<any> {
const iframeWindow = this.getWindow();
if (!iframeWindow) {
if (!iframeWindow || this.buffer.length) {
return new Promise((resolve, reject) => {
this.buffer.push({ event, resolve, reject });
});
Expand Down
7 changes: 7 additions & 0 deletions lib/channels/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export class Channel {

private events: EventsKeyValue = {};

private data: Record<string, any> = {};

private readonly transport: ChannelTransport;

constructor({ transport, async = false }: ChannelArgs = {}) {
Expand Down Expand Up @@ -87,6 +89,10 @@ export class Channel {
}
}

last(eventName: string) {
return this.data[eventName];
}

eventNames() {
return Object.keys(this.events);
}
Expand Down Expand Up @@ -134,6 +140,7 @@ export class Channel {
if (listeners && (isPeer || event.from !== this.sender)) {
listeners.forEach(fn => !(isPeer && fn.ignorePeer) && fn(...event.args));
}
this.data[event.type] = event.args;
}

private onceListener(eventName: string, listener: Listener) {
Expand Down
39 changes: 39 additions & 0 deletions lib/client-api/src/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { ADDON_STATE_CHANGED, ADDON_STATE_SET } from '@storybook/core-events';

import {
addons,
HooksContext,
applyHooks,
useMemo,
Expand All @@ -25,3 +28,39 @@ export {
useStoryContext,
useParameter,
};

export function useAddonState<S>(addonId: string, defaultState?: S): [S, (s: S) => void] {
const channel = addons.getChannel();

const [lastValue] =
channel.last(`${ADDON_STATE_CHANGED}-manager-${addonId}`) ||
channel.last(`${ADDON_STATE_SET}-manager-${addonId}`) ||
[];

const [state, setState] = useState<S>(lastValue || defaultState);

const allListeners = useMemo(
() => ({
[`${ADDON_STATE_CHANGED}-manager-${addonId}`]: (s: S) => setState(s),
[`${ADDON_STATE_SET}-manager-${addonId}`]: (s: S) => setState(s),
}),
[addonId]
);

const emit = useChannel(allListeners, [addonId]);

useEffect(() => {
// init
if (defaultState !== undefined && !lastValue) {
emit(`${ADDON_STATE_SET}-client-${addonId}`, defaultState);
}
}, [addonId]);

return [
state,
s => {
setState(s);
emit(`${ADDON_STATE_CHANGED}-client-${addonId}`, s);
},
];
}
4 changes: 4 additions & 0 deletions lib/core-events/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ enum events {
STORIES_COLLAPSE_ALL = 'storiesCollapseAll',
STORIES_EXPAND_ALL = 'storiesExpandAll',
DOCS_RENDERED = 'docsRendered',
ADDON_STATE_CHANGED = 'addonStateChanged',
ADDON_STATE_SET = 'addonStateSet',
}

// Enables: `import Events from ...`
Expand Down Expand Up @@ -50,4 +52,6 @@ export const {
STORIES_EXPAND_ALL,
STORY_THREW_EXCEPTION,
DOCS_RENDERED,
ADDON_STATE_CHANGED,
ADDON_STATE_SET,
} = events;
Loading

0 comments on commit aeade42

Please sign in to comment.