storybook icon indicating copy to clipboard operation
storybook copied to clipboard

Button control type

Open lewispeel opened this issue 3 years ago • 18 comments

With the Knobs addon I could include a button and associated handler... is this possible with the Controls addon?

import { button } from '@storybook/addon-knobs';
 
const label = 'Do Something';
const handler = () => doSomething('foobar');
const groupId = 'GROUP-ID1';
 
button(label, handler, groupId);

lewispeel avatar Aug 13 '20 12:08 lewispeel

Not currently possible, adding it as a feature request

shilman avatar Aug 13 '20 13:08 shilman

Hi everyone! Seems like there hasn't been much going on in this issue lately. If there are still questions, comments, or bugs, please feel free to continue the discussion. Unfortunately, we don't have time to get to every issue. We are always open to contributions so please send us a pull request if you would like to help. Inactive issues will be closed after 30 days. Thanks!

stale[bot] avatar Sep 05 '20 19:09 stale[bot]

I'll throw my two cents in that buttons are critical for us—we'll have to keep using knobs unless buttons are added to controls as well.

kendagriff avatar Oct 28 '20 09:10 kendagriff

@lewispeel, @kendagriff and anyone else who has a need for this feature:

Can you describe the ways in which you intend to use this "button" control? How are you currently using Knobs? Can you give specific examples of real use cases? What problem does it solve for you?

Even though there's a PR open that adds a button control, we've decided to put that on hold and go back to the drawing board. Right now there's two use cases we've identified that would be addressed with this button control, but they're distinct enough to be separate features altogether, with their own purpose-built UI and API. Are there more use cases we haven't considered?

Read more here: https://github.com/storybookjs/storybook/pull/14330#issuecomment-857532439

ghengeveld avatar Jun 09 '21 09:06 ghengeveld

In our case we're using a button to save the current state of the component to localStorage.

But speaking generally, I think it's a straightforward need: imagine needing a button that increments a counter which is in turn used as an input to the component you're testing in Storybook. I can't think of any other reasonable control accept to kind of fake it with a number or range input.

kendagriff avatar Jun 09 '21 15:06 kendagriff

@ghengeveld another use case from Discord #angular channel

        allExpand: button("Expand all", () => {
          openState.item1 = true;
          openState.item2 = true;
        }),
        allCollapse: button("Collapse all", () => {
          openState.item1 = false;
          openState.item2 = false;
        }),

shilman avatar Jun 11 '21 04:06 shilman

@kendagriff can you explain what you use that localStorage data for afterwards? Does the component retrieve that data from localStorage? Storybook itself is moving away from localStorage, instead using the URL to track state (args and globals, specifically). Only Storybook UI state (panel size and such) is still in localStorage.

The localStorage example is an "interaction" or "side-effect", technically unrelated to args (although it uses args as input, it doesn't directly affect any Storybook state). The "counter" example that mutates args is a different use case, which we currently think of as "macros". The "expand/collapse all" example is also a macro, because it similarly mutates args.

The tricky part is going to be creating a UI that makes sense for these various use cases. Does it really have to coexist with Controls, or can it be a separate addon (and thus, tab in the addons panel)?

ghengeveld avatar Jun 16 '21 11:06 ghengeveld

@ghengeveld For me, I'd like a button control so I can call methods on my components. We would use it to simulate focus/unfocus states, show/hide animations or something else that can't be done by passing a value to a property. My components aren't built with React so not everything can be done with passing props.

lewispeel avatar Jul 01 '21 14:07 lewispeel

I have similar usecase as @lewispeel - I have canvas application exposes API which need to be triggered somehow. Knobs does that nicely.

GuskiS avatar Jul 01 '21 19:07 GuskiS

It could be argued that this situation is more complicated than what a story should be doing anyway, but the one knob I had trouble converting to controls was a knob that updates the route. I am considering trying an alternate implementation of Angular's router that doesn't actually update the page's location for stories, because if Storybook or an addon starts using the # part of the url I would have to find another solution anyway. For now though, I am using the following solution.

I can't decide if the route makes sense as an arg or should be handled by another addon. I think of the url as the component's input, but the component is observing the router instead of a component input.

With knobs, I had a few buttons that would set the route to different locations. The component wasn't re-rendering, so it would still trigger any router changing features, such as animations or data resolvers. I could see it being argued that those things only need to be documented, since they aren't necessarily features of the component itself, but I wanted a fully working router example that could be played with to see how the component reacts to different router events.

My solution was to use a select control where each option is one of the routes that used be a button knob. The select control has an onChange callback, but it happens in the manager, so I send a message containing the selected option back to the preview with a channel. Using the onChange and sending my own message avoids the storyArgsUpdated event, but still lets me run code in the preview.

My story:

export const Example: Story = (args) => ({
  moduleMetadata: {
    declarations: [
      StoryEmptyComponent
    ],
    providers: [
      StoryUsersDataService,
      StoryUserIdToNameResolver
    ],
    imports: [
      BrowserAnimationsModule,
      BrowserModule,
      RouterModule.forRoot([
        {
          path: 'users',
          component: StoryEmptyComponent,
          data: {
            breadcrumb: 'Users'
          },
          children: [
            {
              path: ':userId',
              component: StoryEmptyComponent,
              data: { },
              resolve: {
                breadcrumb: StoryUserIdToNameResolver
              }
            }
          ]
        }
      ], { useHash: true }),
      // NOTE: This could probably be replaced with Storybook's new `setup` feature.
      StoryInitialRouteModule.forRoot('/users/123')
    ]
  },
  props: args,
  template: `
    <seam-breadcrumbs></seam-breadcrumbs>
    <router-outlet></router-outlet>
  `
})
Example.argTypes = {
  routes: routesArgType([
    '/users',
    '/users/123',
    '/users/987',
    '/users/999'
  ])
}

The function building the argType to replace my routing button knobs:

import { ArgType } from '@storybook/addons'

declare const __STORYBOOK_ADDONS: any

function goToHashUrl(url: string): void { location.hash = `#${url}` }

// I could directly set the iframe, but I didn't want to assume that iframe will always be there and let me change the location.
// function goToHashUrl(url: string): void { (document.querySelector('#storybook-preview-iframe') as any).contentWindow.location.hash = `#${url}` }

__STORYBOOK_ADDONS.getChannel().on('custom/go-to-hash', (data: { hash: string }) => {
  goToHashUrl(data.hash)
})

export function routesArgType(routes: string[]): ArgType {
  return {
    options: routes,
    control: {
      type: 'select',
      // Runs in the 'manager', so I am emitting to a channel in the 'preview'.
      onChange: (url: string) => { __STORYBOOK_ADDONS.getChannel().emit('custom/go-to-hash', { hash: url }) }
    }
  }
}

Marklb avatar Jul 01 '21 21:07 Marklb

In my case I have some components that expose methods with useImperativeHandle hook, like "animateIn()" and "animateOut()". Button controls would be very handy to test these methods.

edgardz avatar Jul 06 '21 16:07 edgardz

Another use case from discussion https://github.com/storybookjs/storybook/discussions/15779

I'm using button knobs to push items to an array that is used as component input:

export const defaultElements: string[] = ["test1", "test2", "test3"];

const addElementAction = () => {
  defaultElements.push("test");
};

export const Primary = () => ({
  component: ListComponent,
  props: {
    elements: defaultElements, // elements is an input to ListComponent
    addElement: button('Add Element', addElementAction),
  },
});

There doesn't seem to be a matching replacement for this using addon-controls

dpeger avatar Aug 09 '21 08:08 dpeger

I also have the same use case as @dpeger - I have some components that take an array of data as a prop, and need simple controls to add data, remove data, or reset the array. Without the ability to control the mock data like this, I would be unable to use Storybook as my dev environment for these components. This was easy to build with Knobs, but appears impossible with Controls.

vivshaw avatar Aug 11 '21 22:08 vivshaw

Likewise as with others above. For me the problem boils down to this:

When building components that have rich animations, the component's interface is not just defined by props - instead it's defined by props + some methods, where the methods trigger animations.

For example, imagine a Torch component, that you want to flash for a split second. Yes you could pass in a prop isFlashing that you quickly set from false to true to false but that's not only clunky it's the wrong abstraction really. If the component has a method, e.g. torch.flash() this is done very easily.

Given that React functional components have no methods they've provided useImperativeHandle https://reactjs.org/docs/hooks-reference.html#useimperativehandle as an equivalent, but certainly with WebComponents, React class components, and other libraries compatible with Storybook you can call methods on components.

So back to controls... how would these component methods be callable using controls? (e.g. a button that calls torch.flash())

markevans-sky avatar Oct 06 '21 10:10 markevans-sky

My 2 cents. We use buttons in Knobs for a wide array of things. From triggering animations, clearing localstorage and resetting components, to pulling data from different environments. Not sure how to go about doing this with Controls...

aarfing avatar Mar 31 '22 08:03 aarfing

This is essentially why we need button controls. Not everyone is on modern frameworks. https://dev.to/joelstransky/render-callbacks-in-storybook-5c0c

joelstransky avatar Apr 01 '22 18:04 joelstransky

I did it like this:

postinstall.js:

const path = require('path');
const fs = require('fs');

addCustomControls();

function addCustomControls() {
	const file = path.resolve('node_modules', '@storybook', 'components', 'dist', 'esm', 'index-681e4b07.js');
	replaceInFile(
		file,
		'var Control=Controls[control.type]||NoControl',
		'var Control=(Object.assign({}, Controls, (window["STORYBOOK_CUSTOM_CONTROLS"] || {})))[control.type]||NoControl',
	);
}

function replaceInFile(file, search, value) {
	let content = fs.readFileSync(file, 'utf-8');
	content = content.replace(search, value);
	fs.writeFileSync(file, content, 'utf-8');
}

manager.js:

const React = require('react');
const { Form } = require('@storybook/components');

window["STORYBOOK_CUSTOM_CONTROLS"] = {
	'button': function ({ argType, value, onChange }) {
		return React.createElement(Form.Button, {
			onClick: function(){
				value = typeof value === 'number' ? value : 0;
				onChange(value + 1);
			},
		}, argType.name);
	},
}

in stories:

export default {
	//  ...
	argTypes: {
		Prev: {
			name: 'Prev Slide',
			control: 'button',
		}
	}
	// ...
}

export const Slider = ({ Prev }) => {
	// ...
	const initPrev = useRef(Prev);
	useEffect(() => {
		if (initPrev.current !== Prev) {
			model.PrevSlide();
		}
	}, [Prev]);
	// ...
}

image

Vovencia avatar Sep 08 '22 14:09 Vovencia

Our use-case: a bunch of our components have internal state and will react on external events. A button is the most suitable of the bunch to make this work.

Vovencia exposes a cool little thing though. Why not expose the Controls dictionary and allow users to add or overwrite control types? Then I think anyone could create another addon to allow for button types from within the controls window. This might also improve controls that accept objects, so we can build a tiny UI to set relevant properties which non-technical users can use.

didii avatar Oct 17 '22 17:10 didii