Open dorshinar opened 2 years ago
Are there any news to this issue? I am using Storybook 7 because I hoped that it can solve the problem but I did not found a solution and the workaround, described in this issue, does not work anymore because "@storybook/client-api" is not available anymore.
I'm not sure about Storybook 7, but this is what we are using for Storybook 6.5:
import { useArgs } from '@storybook/store';
const Template: Story<ComponentProps<typeof Drawer>> = (args) => {
const [, setArgs] = useArgs();
const onClose = () => {
setArgs({ ...args, isOpen: false });
};
return (
<Drawer {...args} onClose={onClose}>
Content goes here
</Drawer>
);
};
@nikparo thank you, this is exactly what I was looking for for my project!
One note, setArgs
doesn’t need the old args passed in—it appears to work like the old React Component.setState—so you can simplify it to this:
setArgs({ isOpen: false })
Using this does not work for me using SB7 and angular, my browser just hangs, any idea's?
Does only React have a solution for this?
I took a look in the source code and made it work in Angular:
import type { Channel } from "@storybook/channels";
import { UPDATE_STORY_ARGS } from "@storybook/core-events";
export const MyStory: StoryObj<MyComponent> = {
render: (args, { id }) => {
args.btnClicked = () => {
const channel = (window as any).__STORYBOOK_ADDONS_CHANNEL__ as Channel;
channel.emit(UPDATE_STORY_ARGS, {
storyId: id,
updatedArgs: { clicked: true },
});
};
....
Thanks @boyanio ! Made it work in Vue using a decorator:
decorators: [
(story, ctx) => {
const onUpdateModelValue = ctx.args['onUpdate:modelValue']
ctx.args['onUpdate:modelValue'] = (value) => {
onUpdateModelValue?.(value)
const channel = (window as any).__STORYBOOK_ADDONS_CHANNEL__ as Channel
channel.emit(UPDATE_STORY_ARGS, {
storyId: ctx.id,
updatedArgs: { modelValue: value },
})
ctx.args.modelValue = value
}
return story()
},
],
I wrote a reusable decorator to use with v-model:someProp
with autocompletion. Hopefully it's useful to someone else.
import type { Channel } from '@storybook/channels'
import { UPDATE_STORY_ARGS } from '@storybook/core-events'
import type { Decorator } from '@storybook/vue3'
type OnUpdateProps<T> = T extends `onUpdate:${infer U}` ? U : never
/**
* @example
* // updates `modelValue` on `onUpdate:modelValue` event
* const decorators = [withVModel()]
* // updates `todos` on `onUpdate:todos` event and
* // updates `items` on `onUpdate:items` event
* const decorators = [withVModel(['todos', 'items'])]
*/
export function withVModel<
TArgs extends { 'onUpdate:modelValue'?: unknown },
>(): Decorator<TArgs>
export function withVModel<TArgs extends Record<string, unknown>>(
props: OnUpdateProps<keyof TArgs>[],
): Decorator<TArgs>
export function withVModel(props?: string[]): Decorator {
return (story, ctx) => {
const normalizedProps = props ?? ['modelValue']
for (const prop of normalizedProps) {
const onUpdate = ctx.args[`onUpdate:${prop}`]
ctx.args[`onUpdate:${prop}`] = (value: unknown) => {
if (typeof onUpdate === 'function') onUpdate(value)
const channel = (window as any).__STORYBOOK_ADDONS_CHANNEL__ as Channel
channel.emit(UPDATE_STORY_ARGS, {
storyId: ctx.id,
updatedArgs: { [prop]: value },
})
ctx.args[prop] = value
}
}
return story()
}
}
Also a js version which doesn't have as good autocompletion:
import { UPDATE_STORY_ARGS } from '@storybook/core-events'
/**
* @template TArgs
* @param { (keyof TArgs)[] } [props]
* @returns { import('@storybook/vue3').Decorator<TArgs> }
* @example
* // updates `modelValue` on `onUpdate:modelValue` event
* const decorators = [withVModel()]
* // updates `todos` on `onUpdate:todos` event and
* // updates `items` on `onUpdate:items` event
* const decorators = [withVModel(['todos', 'items'])]
*/
export function withVModel(props) {
return (story, ctx) => {
const normalizedProps = props ?? ['modelValue']
for (const prop of normalizedProps) {
const onUpdate = ctx.args[`onUpdate:${prop}`]
ctx.args[`onUpdate:${prop}`] = (value) => {
if (typeof onUpdate === 'function') onUpdate(value)
const channel = window.__STORYBOOK_ADDONS_CHANNEL__
channel.emit(UPDATE_STORY_ARGS, {
storyId: ctx.id,
updatedArgs: { [prop]: value },
})
ctx.args[prop] = value
}
}
return story()
}
}
For anyone interested in this, @storybook/client-api
was renamed to @storybook/preview-api
in SB 7.
The previous code for Vue can be written as:
import { useArgs } from '@storybook/preview-api'
import type { Args } from '@storybook/types'
import type { Decorator } from '@storybook/vue3'
type OnUpdateProps<T> = T extends `onUpdate:${infer U}` ? U : never
/**
* @example
* // set all `onUpdate:[prop]` events to change `[prop]` props
* const decorators = [withVModel()]
* // updates `todos` on `onUpdate:todos` event and
* // updates `items` on `onUpdate:items` event
* const decorators = [withVModel(['todos', 'items'])]
*/
export default function withVModel<TArgs extends Record<string, unknown>>(
props?: OnUpdateProps<keyof TArgs>[],
): Decorator<TArgs> {
return (story, ctx) => {
const [, updateArgs] = useArgs()
const normalizedProps =
props ??
Object.values(ctx.argTypes)
.filter(({ name }) => name.startsWith('update:'))
.map(({ name }) => name.slice('update:'.length))
for (const prop of normalizedProps) {
const args = ctx.args as Args
const onUpdate = args[`onUpdate:${prop}`]
args[`onUpdate:${prop}`] = (value: unknown) => {
if (typeof onUpdate === 'function') onUpdate(value)
updateArgs({ [prop]: value })
}
}
return story()
}
}
These look like great gems, but whenever I try to use them it falls to pieces and doesn't work for some reason..
Example @boyanio
import type { Channel } from "@storybook/channels";
import { UPDATE_STORY_ARGS } from "@storybook/core-events";
export const MyStory: StoryObj<MyComponent> = {
render: (args, { id }) => {
args.btnClicked = () => {
const channel = (window as any).__STORYBOOK_ADDONS_CHANNEL__ as Channel;
channel.emit(UPDATE_STORY_ARGS, {
storyId: id,
updatedArgs: { clicked: true },
});
};
....
Questions:
render
method, all the the @Input
args (args
on the StoryObj
) just get undefined?btnClicked
is an @Output
I assume, you're assigning a function to it that is supposed to trigger whenever the component emits to that output? Is that how it works? What actually runs that function? Outputs are EventEmitter
, so does Storybook act as a glue in-between here or what?Would love a full example somewhere to poke at!
@prewk I wrote a generic decorator that should work for any framework:
// argsUpdater.ts
import { useArgs } from '@storybook/preview-api'
import type { DecoratorFunction, Renderer, StoryContext } from '@storybook/types'
type Fn = (...args: any) => any
/**
* @example
* {
* decorators: [argsUpdater('btnClicked', () => ({ clicked: true }))]
* }
* @see https://github.com/storybookjs/storybook/issues/17089
*/
export function argsUpdater<
TRenderer extends Renderer,
TArgs,
TArgName extends keyof {
[K in keyof TArgs as Fn extends TArgs[K] ? K : never]: TArgs[K]
},
>(
argName: TArgName,
updater: (
this: StoryContext<TRenderer, TArgs>,
...args: TArgs[TArgName] extends Fn ? Parameters<TArgs[TArgName]> : []
) => TArgs,
): DecoratorFunction<TRenderer, TArgs> {
return (storyFn, ctx) => {
const bindedUpdater = updater.bind(ctx)
const [, updateArgs] = useArgs()
const prevValue = ctx.args[argName]
ctx.args[argName] = ((...fnArgs: any) => {
updateArgs(bindedUpdater(...fnArgs) as any)
if (typeof prevValue === 'function') {
return prevValue(...fnArgs)
}
}) as TArgs[TArgName]
return storyFn()
}
}
You can use it as:
import { argsUpdater } from "./argsUpdater";
export const MyStory: StoryObj<MyComponent> = {
decorators: [argsUpdater('btnClicked', () => ({ clicked: true }))],
....
If you have a value
prop and an onChange
prop, you can use it as:
import { argsUpdater } from "./argsUpdater";
export const MyStory: StoryObj<MyComponent> = {
decorators: [argsUpdater('onChange', (value) => ({ value }))],
....
If you need the story context (e.g. to access the values of other args), you can use this
(without using the arrow function), but probably in most cases you won't need it.
Let me know if it works.
Thanks! Had high hopes for it, it uses the exact API I was trying to build, but:
Angular 16.2.2 and Storybook 7.3.2
edit: ..and I mean, how does setting an argument like this ctx.args[argName] = ((...fnArgs: any) => {
cause a subscription to that component's output (an EventEmitter
)? Maybe it works in React but not in Angular?
Please note that using updateArgs, causes the Story to re-render, which in-turn, causes any Testing Spies/Mocks to get cleared and Interaction Tests to fail...
one can see the forced re-render here: https://github.com/storybookjs/storybook/blob/v7.6.0-alpha.6/code/lib/preview-api/src/modules/preview-web/Preview.tsx#L271
Which is unnecessary in this case, since the args are updated from the component itself usually. Meaning, the component is already aware and has updated/rendered itself with the updated args.
A dirty workaround is as follows:
import { addons } from '@storybook/preview-api';
import { STORY_ARGS_UPDATED } from '@storybook/core-events';
const storyStoreArgs = global.__STORYBOOK_STORY_STORE__.args;
const storyId = 'your-story-id';
storyStoreArgs.update(storyId, {
[argName]: argValue,
});
addons.getChannel().emit(STORY_ARGS_UPDATED, {
storyId,
args: storyStoreArgs.get(storyId),
});
Basically, this just skips the re-render of the story but the URL args and Story Controls get updated properly, which is the actual desired effect.
The ideal solution would be to enhance the useArgs
-> updateArgs
hook, with an optional parameter, to not re-render...
// argsUpdater.ts import { useArgs } from '@storybook/preview-api' import type { DecoratorFunction, Renderer, StoryContext } from '@storybook/types' type Fn = (...args: any) => any /** * @example * { * decorators: [argsUpdater('btnClicked', () => ({ clicked: true }))] * } * @see https://github.com/storybookjs/storybook/issues/17089 */ export function argsUpdater< TRenderer extends Renderer, TArgs, TArgName extends keyof { [K in keyof TArgs as Fn extends TArgs[K] ? K : never]: TArgs[K] }, >( argName: TArgName, updater: ( this: StoryContext<TRenderer, TArgs>, ...args: TArgs[TArgName] extends Fn ? Parameters<TArgs[TArgName]> : [] ) => TArgs, ): DecoratorFunction<TRenderer, TArgs> { return (storyFn, ctx) => { const bindedUpdater = updater.bind(ctx) const [, updateArgs] = useArgs() const prevValue = ctx.args[argName] ctx.args[argName] = ((...fnArgs: any) => { updateArgs(bindedUpdater(...fnArgs) as any) if (typeof prevValue === 'function') { return prevValue(...fnArgs) } }) as TArgs[TArgName] return storyFn() } }
import { useArgs } from '@storybook/preview-api'
import type { DecoratorFunction, Renderer, StoryContext } from '@storybook/types'
type Fn<TArgs extends unknown[] = unknown[], TReturn = unknown> = (...args: TArgs) => TReturn
/**
* @example
* {
* decorators: [argsUpdater('btnClicked', () => ({ clicked: true }))]
* }
* @see https://github.com/storybookjs/storybook/issues/17089#issuecomment-1704390992
*/
function argsUpdater<
TRenderer extends Renderer,
TArgs,
TArgName extends keyof {
[K in keyof TArgs as Fn extends NonNullable<TArgs[K]> ? K : never]: TArgs[K]
},
>(
argName: TArgName,
updater: (
this: StoryContext<TRenderer, TArgs>,
...args: NonNullable<TArgs[TArgName]> extends Fn<infer TFnArgs, unknown> ? TFnArgs : []
) => Partial<TArgs>,
): DecoratorFunction<TRenderer, TArgs> {
return (storyFn, ctx) => {
const bindedUpdater = updater.bind(ctx)
const [, updateArgs] = useArgs()
const prevValue = ctx.args[argName]
ctx.args[argName] = ((
...fnArgs: NonNullable<TArgs[TArgName]> extends Fn<infer TFnArgs, unknown> ? TFnArgs : []
) => {
updateArgs(bindedUpdater(...fnArgs))
if (typeof prevValue === 'function') {
return prevValue(...fnArgs)
}
}) as TArgs[TArgName]
return storyFn()
}
}
export default argsUpdater
Update some type define:
any
to improve type safety.NonNullable
for TArgs[TArgName]
to handle cases where TArgs[TArgName]
can be undefined.updater
function to Partial<TArgs>
since returning TArgs
is not the correct return type.
I'd really like a way to update args declared in either
argTypes
orTemplate.args
via actions without manually wiring up the code. Currently, the suggested solution looks like so:This forces me to manually call
updateArgs
andonChange
(so thevalue
is updated and I get feedback in theactions
tab).I'd like to propose a solution that looks like so:
The output of
onEmit
should be used much like the param passed toupdateArgs
, and update the relevant args. This way I can define the action once, and all my templates inherit it.