storybookjs / storybook

Storybook is the industry standard workshop for building, documenting, and testing UI components in isolation
https://storybook.js.org
MIT License
84.69k stars 9.32k forks source link

Easy way to update args from actions #17089

Open dorshinar opened 2 years ago

dorshinar commented 2 years ago

I'd really like a way to update args declared in either argTypes or Template.args via actions without manually wiring up the code. Currently, the suggested solution looks like so:

import { useArgs } from '@storybook/client-api';
import React from 'react';

const Template = (args) => {
    const [{ value, onChange }, updateArgs] = useArgs();

    return (
        <Input
            {...args}
            value={value}
            onChange={(e) => {
                updateArgs({ value: e.target.value });
                onChange(e);
            }}
        />
    );
};

export const Primary = Template.bind({});

export default {
    title: 'Input',
    component: Input,
    argTypes: {
        onChange: {
            action: 'changed',
        },
        value: {
            type: 'string',
        },
    },
}

This forces me to manually call updateArgs and onChange (so the value is updated and I get feedback in the actions tab).

I'd like to propose a solution that looks like so:

const Template = (args) => {
  return <Input {...args} />;
};

export const Primary = Template.bind({});

export default {
  title: "Input",
  component: Input,
  argTypes: {
    onChange: {
      action: "changed",
      onEmit: (e) => ({value: e.target.value}),
    },
    value: {
      type: "string",
    },
  },
};

The output of onEmit should be used much like the param passed to updateArgs, and update the relevant args. This way I can define the action once, and all my templates inherit it.

MWI-msg commented 1 year 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.

nikparo commented 1 year ago

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>
    );
};
mrcoles commented 1 year ago

@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 })
thebspin commented 1 year ago

Using this does not work for me using SB7 and angular, my browser just hangs, any idea's?

prewk commented 1 year ago

Does only React have a solution for this?

boyanio commented 1 year ago

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 },
      });
    };
    ....
tfoxy commented 1 year ago

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()
    },
  ],
tfoxy commented 1 year ago

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()
  }
}
tfoxy commented 1 year ago

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()
  }
}
prewk commented 1 year ago

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:

  1. Whenever I use the render method, all the the @Input args (args on the StoryObj) just get undefined?
  2. 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?
  3. Whatever I assign to it (function like you did, EventEmitter, try to subscribe..) it's never triggered when the component emits to an output

Would love a full example somewhere to poke at!

tfoxy commented 1 year ago

@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.

prewk commented 1 year ago

Thanks! Had high hopes for it, it uses the exact API I was trying to build, but:

  1. The argName type is wrong, it becomes a union of component methods instead of the outputs (probably fixable/escape-hatchable, not super important)
  2. The callback never runs, and the inputs (therefore) never change

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?

antoniosZ commented 1 year ago

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...

Nathan7139 commented 6 months ago
// 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:

  1. Avoid using any to improve type safety.
  2. Add NonNullable for TArgs[TArgName] to handle cases where TArgs[TArgName] can be undefined.
  3. Update the return type of the updater function to Partial<TArgs> since returning TArgs is not the correct return type.