Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Core: Add global args feature #10015

Merged
merged 22 commits into from
Mar 11, 2020
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -58,3 +58,37 @@ addons.addPanel('useAddonState', {
title: 'useAddonState',
render: StatePanel,
});

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

<form
onSubmit={e => {
e.preventDefault();
setGlobalArgs(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,
CHANGE_STORY_ARGS,
CHANGE_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(CHANGE_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 @@ -57,6 +57,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 @@ -76,7 +80,8 @@ export type State = Other &
NotificationState &
VersionsSubState &
RouterData &
ShortcutsSubState;
ShortcutsSubState &
GlobalArgsSubState;

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

interface OtherAPI {
Expand Down Expand Up @@ -182,6 +188,7 @@ class ManagerProvider extends Component<ManagerProviderProps, State> {
initStories,
initURL,
initVersions,
initGlobalArgs,
].map(initModule => initModule({ ...routeData, ...apiData, state: this.state }));

// Create our initial state by combining the initial state of all modules, then overlaying any saved state
Expand Down Expand Up @@ -437,3 +444,12 @@ export function useArgs() {

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

export function useGlobalArgs() {
tmeasday marked this conversation as resolved.
Show resolved Hide resolved
const {
state: { globalArgs },
api: { setGlobalArgs },
tmeasday marked this conversation as resolved.
Show resolved Hide resolved
} = useContext(ManagerContext);

return [globalArgs, setGlobalArgs];
tmeasday marked this conversation as resolved.
Show resolved Hide resolved
}
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 { CHANGE_GLOBAL_ARGS, GLOBAL_ARGS_CHANGED } from '@storybook/core-events';
import { Args, Module, API } from '../index';

export interface SubState {
globalArgs: Args;
}

export interface SubAPI {
setGlobalArgs: (newGlobalArgs: Args) => void;
tmeasday marked this conversation as resolved.
Show resolved Hide resolved
}

const initGlobalArgsApi = ({ store }: Module) => {
let fullApi: API;
const setGlobalArgs = (newGlobalArgs: Args) => {
tmeasday marked this conversation as resolved.
Show resolved Hide resolved
if (!fullApi) throw new Error('Cannot set global args until api has been initialized');

fullApi.emit(CHANGE_GLOBAL_ARGS, newGlobalArgs);
};

const api: SubAPI = {
setGlobalArgs,
tmeasday marked this conversation as resolved.
Show resolved Hide resolved
};

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_CHANGED, (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 { CHANGE_GLOBAL_ARGS, GLOBAL_ARGS_CHANGED } 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_CHANGED', () => {
const mod = createMockModule();
const { state, init } = initGlobalArgs(mod);
mod.store.setState(state);

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

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

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

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

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

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

api.setGlobalArgs({ a: 'b' });
tmeasday marked this conversation as resolved.
Show resolved Hide resolved
expect(fullApi.emit).toHaveBeenCalledWith(CHANGE_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
// `setArgs` will update its args. You can pass a subset of the args; other args will not be changed.
const [args, setArgs] = 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, setGlobalArgs] = useGlobalArgs();
tmeasday marked this conversation as resolved.
Show resolved Hide resolved
```
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