Skip to content

Commit

Permalink
Merge pull request #10015 from storybookjs/feature/global-args
Browse files Browse the repository at this point in the history
Core: Add global args feature
  • Loading branch information
shilman authored Mar 11, 2020
2 parents 41d7d06 + 6dbdf7f commit 4f65ccb
Show file tree
Hide file tree
Showing 18 changed files with 534 additions and 19 deletions.
1 change: 1 addition & 0 deletions app/vue/src/client/preview/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ const defaultContext: StoryContext = {
kind: 'unspecified',
parameters: {},
args: {},
globalArgs: {},
};

function decorateStory(
Expand Down
38 changes: 36 additions & 2 deletions examples/dev-kits/manager.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React from 'react';
import React, { useState } from 'react';
import { PropTypes } from 'prop-types';
import { Button } from '@storybook/react/demo';
import { addons } from '@storybook/addons';
import { useAddonState, useStoryState } from '@storybook/api';
import { useAddonState, useStoryState, useGlobalArgs } from '@storybook/api';
import { themes } from '@storybook/theming';
import { AddonPanel } from '@storybook/components';

Expand Down Expand Up @@ -59,3 +59,37 @@ addons.addPanel('useAddonState', {
title: 'useAddonState',
render: StatePanel,
});

const GlobalArgsPanel = ({ active, key }) => {
const [globalArgs, updateGlobalArgs] = useGlobalArgs();
const [globalArgsInput, updateGlobalArgsInput] = useState(JSON.stringify(globalArgs));
return (
<AddonPanel key={key} active={active}>
<div>
<h2>Global Args</h2>

<form
onSubmit={e => {
e.preventDefault();
updateGlobalArgs(JSON.parse(globalArgsInput));
}}
>
<textarea value={globalArgsInput} onChange={e => updateGlobalArgsInput(e.target.value)} />
<br />
<button type="submit">Change</button>
</form>
</div>
</AddonPanel>
);
};

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

addons.addPanel('useGlobalArgs', {
id: 'useGlobalArgs',
title: 'useGlobalArgs',
render: GlobalArgsPanel,
});
32 changes: 32 additions & 0 deletions examples/dev-kits/stories/addon-useglobalargs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React from 'react';
import PropTypes from 'prop-types';

export default {
title: 'addons/useGlobalArgs',
};

export const PassedToStory = ({ globalArgs }) => {
return (
<div>
<h3>Global args:</h3>
<pre>{JSON.stringify(globalArgs)}</pre>
</div>
);
};

PassedToStory.propTypes = {
globalArgs: PropTypes.shape({}).isRequired,
};

export const SecondStory = ({ globalArgs }) => {
return (
<div>
<h3>Global args (2):</h3>
<pre>{JSON.stringify(globalArgs)}</pre>
</div>
);
};

SecondStory.propTypes = {
globalArgs: PropTypes.shape({}).isRequired,
};
68 changes: 68 additions & 0 deletions examples/official-storybook/stories/core/globalArgs.stories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';

import { useGlobalArgs } from '@storybook/client-api';

// eslint-disable-next-line react/prop-types
const ArgUpdater = ({ args, updateArgs }) => {
const [argsInput, updateArgsInput] = useState(JSON.stringify(args));

return (
<div>
<h3>Hooks args:</h3>
<pre>{JSON.stringify(args)}</pre>
<form
onSubmit={e => {
e.preventDefault();
updateArgs(JSON.parse(argsInput));
}}
>
<textarea value={argsInput} onChange={e => updateArgsInput(e.target.value)} />
<br />
<button type="submit">Change</button>
</form>
</div>
);
};

export default {
title: 'Core/Global Args',
decorators: [
story => {
const [globalArgs, updateGlobalArgs] = useGlobalArgs();

return (
<>
{story()}
<ArgUpdater args={globalArgs} updateArgs={updateGlobalArgs} />
</>
);
},
],
};

export const PassedToStory = ({ globalArgs }) => {
return (
<div>
<h3>Global args:</h3>
<pre>{JSON.stringify(globalArgs)}</pre>
</div>
);
};

PassedToStory.propTypes = {
globalArgs: PropTypes.shape({}).isRequired,
};

export const SecondStory = ({ globalArgs }) => {
return (
<div>
<h3>Global args (2):</h3>
<pre>{JSON.stringify(globalArgs)}</pre>
</div>
);
};

SecondStory.propTypes = {
globalArgs: PropTypes.shape({}).isRequired,
};
14 changes: 14 additions & 0 deletions lib/addons/src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
STORY_RENDERED,
DOCS_RENDERED,
UPDATE_STORY_ARGS,
UPDATE_GLOBAL_ARGS,
} from '@storybook/core-events';
import { addons } from './index';
import { StoryGetter, StoryContext, Args } from './types';
Expand Down Expand Up @@ -427,3 +428,16 @@ export function useArgs(): [Args, (newArgs: Args) => void] {

return [args, updateArgs];
}

/* Returns current value of global args */
export function useGlobalArgs(): [Args, (newGlobalArgs: Args) => void] {
const channel = addons.getChannel();
const { globalArgs } = useStoryContext();

const updateGlobalArgs = useCallback(
(newGlobalArgs: Args) => channel.emit(UPDATE_GLOBAL_ARGS, newGlobalArgs),
[channel]
);

return [globalArgs, updateGlobalArgs];
}
5 changes: 3 additions & 2 deletions lib/addons/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,13 @@ export interface StoryIdentifier {
name: StoryName;
}

export interface StoryContext extends StoryIdentifier {
export type StoryContext = StoryIdentifier & {
[key: string]: any;
parameters: Parameters;
args: Args;
globalArgs: Args;
hooks?: HooksContext;
}
};

export interface WrapperSettings {
options: OptionsParameter;
Expand Down
18 changes: 17 additions & 1 deletion lib/api/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ import initVersions, {
SubState as VersionsSubState,
SubAPI as VersionsAPI,
} from './modules/versions';
import initGlobalArgs, {
SubState as GlobalArgsSubState,
SubAPI as GlobalArgsAPI,
} from './modules/globalArgs';

export { Options as StoreOptions, Listener as ChannelListener };
export { ActiveTabs };
Expand All @@ -66,7 +70,8 @@ export type State = Other &
NotificationState &
VersionsSubState &
RouterData &
ShortcutsSubState;
ShortcutsSubState &
GlobalArgsSubState;

export type API = AddonsAPI &
ChannelAPI &
Expand All @@ -77,6 +82,7 @@ export type API = AddonsAPI &
ShortcutsAPI &
VersionsAPI &
UrlAPI &
GlobalArgsAPI &
OtherAPI;

interface OtherAPI {
Expand Down Expand Up @@ -172,6 +178,7 @@ class ManagerProvider extends Component<ManagerProviderProps, State> {
initStories,
initURL,
initVersions,
initGlobalArgs,
].map(initModule =>
initModule({ ...routeData, ...apiData, state: this.state, fullAPI: this.api })
);
Expand Down Expand Up @@ -405,3 +412,12 @@ export function useArgs(): [Args, (newArgs: Args) => void] {

return [args, (newArgs: Args) => updateStoryArgs(id, newArgs)];
}

export function useGlobalArgs(): [Args, (newGlobalArgs: Args) => void] {
const {
state: { globalArgs },
api: { updateGlobalArgs },
} = useContext(ManagerContext);

return [globalArgs, updateGlobalArgs];
}
41 changes: 41 additions & 0 deletions lib/api/src/modules/globalArgs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { UPDATE_GLOBAL_ARGS, GLOBAL_ARGS_UPDATED } from '@storybook/core-events';
import { Args, Module, API } from '../index';

export interface SubState {
globalArgs: Args;
}

export interface SubAPI {
updateGlobalArgs: (newGlobalArgs: Args) => void;
}

const initGlobalArgsApi = ({ store }: Module) => {
let fullApi: API;
const updateGlobalArgs = (newGlobalArgs: Args) => {
if (!fullApi) throw new Error('Cannot set global args until api has been initialized');

fullApi.emit(UPDATE_GLOBAL_ARGS, newGlobalArgs);
};

const api: SubAPI = {
updateGlobalArgs,
};

const state: SubState = {
// Currently global args always start empty. TODO -- should this be set on the channel at init time?
globalArgs: {},
};

const init = ({ api: inputApi }: { api: API }) => {
fullApi = inputApi;
fullApi.on(GLOBAL_ARGS_UPDATED, (globalArgs: Args) => store.setState({ globalArgs }));
};

return {
api,
state,
init,
};
};

export default initGlobalArgsApi;
59 changes: 59 additions & 0 deletions lib/api/src/tests/globalArgs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import EventEmitter from 'event-emitter';
import { UPDATE_GLOBAL_ARGS, GLOBAL_ARGS_UPDATED } from '@storybook/core-events';

import { Module, API } from '../index';
import initGlobalArgs from '../modules/globalArgs';

function createMockStore() {
let state = {};
return {
getState: jest.fn().mockImplementation(() => state),
setState: jest.fn().mockImplementation(s => {
state = { ...state, ...s };
}),
};
}

function createMockModule() {
// This mock module doesn't have all the fields but we don't use them all in this sub-module
return ({ store: createMockStore() } as unknown) as Module;
}

describe('stories API', () => {
it('sets a sensible initialState', () => {
const { state } = initGlobalArgs(createMockModule());

expect(state).toEqual({
globalArgs: {},
});
});

it('updates the state when the preview emits GLOBAL_ARGS_UPDATED', () => {
const mod = createMockModule();
const { state, init } = initGlobalArgs(mod);
mod.store.setState(state);

const api = new EventEmitter() as API;
init({ api });

api.emit(GLOBAL_ARGS_UPDATED, { a: 'b' });
expect(mod.store.getState()).toEqual({ globalArgs: { a: 'b' } });

api.emit(GLOBAL_ARGS_UPDATED, { a: 'c' });
expect(mod.store.getState()).toEqual({ globalArgs: { a: 'c' } });

// SHOULD NOT merge global args
api.emit(GLOBAL_ARGS_UPDATED, { d: 'e' });
expect(mod.store.getState()).toEqual({ globalArgs: { d: 'e' } });
});

it('emits UPDATE_GLOBAL_ARGS when updateGlobalArgs is called', () => {
const { init, api } = initGlobalArgs({} as Module);

const fullApi = ({ emit: jest.fn(), on: jest.fn() } as unknown) as API;
init({ api: fullApi });

api.updateGlobalArgs({ a: 'b' });
expect(fullApi.emit).toHaveBeenCalledWith(UPDATE_GLOBAL_ARGS, { a: 'b' });
});
});
20 changes: 20 additions & 0 deletions lib/client-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,27 @@ Note that arg values are passed directly to a story -- you should only store the
Both `@storybook/client-api` (preview) and `@storybook/api` (manager) export a `useArgs()` hook that you can use to access args in decorators or addon panels. The API is as follows:

```js
import { useArgs } from '@storybook/client-api'; // or '@storybook/api'

// `args` is the args of the currently rendered story
// `updateArgs` will update its args. You can pass a subset of the args; other args will not be changed.
const [args, updateArgs] = useArgs();
```

## Global Args

Global args are args that are "global" across all stories. They are used for things like themes and internationalization (i18n) in stories, where you want Storybook to "remember" your setting as you browse between stories.

### Initial values of global args

To set initial values of global args, set the `parameters.globalArgs` parameters. Addons can use parameter enhancers (see above) to do this.

### Using global args in an addon

Similar to args, global args are syncronized to the manager and can be accessed via the `useGlobalArgs` hook.

```js
import { useGlobalArgs } from '@storybook/client-api'; // or '@storybook/api'

const [globalArgs, updateGlobalArgs] = useGlobalArgs();
```
1 change: 1 addition & 0 deletions lib/client-api/src/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const defaultContext: StoryContext = {
kind: 'unspecified',
parameters: {},
args: {},
globalArgs: {},
};

export const defaultDecorateStory = (storyFn: StoryFn, decorators: DecoratorFunction[]) =>
Expand Down
Loading

0 comments on commit 4f65ccb

Please sign in to comment.