Storybook: How to define argTypes for properties that are objects?
Asked Answered
T

2

23

I have an input web component that has a very simple API - it has both a get state() and a set state(model). This web component also handles the label which is being used for the input, thus a simple model looks like this:

{
  "value": "Foobar",
  "required": true,
  "disabled": false,
  "label": {
    "text": "Label for input",
    "toolTip": "Tooltip for the input's label",
    "position": "east"
  }
}

Now to describe the argTypes for the stories, I tried this:

export default {
    title: `Components/Input/text-input`,
    argTypes: {
        value: { control: 'text' },
        disabled: { control: 'boolean' },
        required: { control: 'boolean' },
        label: {
            text: { control: 'text' },
            toolTip: { control: 'text' },
            position: { control: 'select', options: ['north', 'south', 'east', 'west'] },
        },
    },
};

it renders in Storybook as follows:

enter image description here

As you can see, I'm not getting proper controls for the label aspect, like e.g. a dropdown for label.position. In fact, I'm getting the very same result even if I don't define argTypes.label at all.

Sure, I could compromise on my state structure and make all label properties flat state properties like labelText, labelPosition, and labelToolTip. But as I understand, Storybook is not meant to influence design decisions this way.

This seems like a very basic requirement and I'm surprised I couldn't find anything on it in the docs.

Question: So how do I achieve that without changing my model structure?

Note: I'm using Storybook HTML v6.3.8.

Edit:

What I've tried so far to work around the current limitations:

Im using a TemplateFactory function to replace the odd Template.bind({}) just to create a new instance. Our components each support setting the component state via an el.state setter.

import { ArgsParser } from '../helper/ArgsParser.js';

export function TemplateFactory(tagName) {
    return (args) => {
        const el = document.createElement(tagName);
        el.state = ArgsParser.expand(args);
        return el;
    };
}
// ArgsParser
export class ArgsParser {
    static flat = (args) => {
        const parsedArgs = {};
        for (const [key, value] of Object.entries(args)) {
            if (['string', 'boolean', 'number'].includes(typeof value)) {
                parsedArgs[key] = value;
            } else {
                for (const innerKey in value) parsedArgs[`${key}.${innerKey}`] = value[innerKey];
            }
        }
        return parsedArgs;
    };

    static expand(args) {
        const parsedArgs = {};
        for (const [key, value] of Object.entries(args)) {
            const parsedKeys = key.split('.');
            if (parsedKeys.length === 1) {
                parsedArgs[key] = value;
            } else {
                const [parentKey, prop] = parsedKeys;
                parsedArgs[parentKey] = parsedArgs[parentKey] ?? {};
                parsedArgs[parentKey][prop] = value[prop];
            }
        }
        return parsedArgs;
    }
}
// custom-text.stories.js
import { TemplateFactory } from '../helper/TemplateFactory.js';
const TAG_NAME = 'custom-text';

export default {
    title: `Components/Input/${TAG_NAME}`,
    argTypes: {
        value: { control: 'text' },
        disabled: { control: 'boolean' },
        required: { control: 'boolean' },
        ['label.text']: { control: 'text' },
        ['label.toolTip']: { control: 'text' },
        ['label.position']: {
            control: { type: 'select', options: ['north', 'south', 'east', 'west'] },
        },
    },
};

export const EmptyEnabled = TemplateFactory(TAG_NAME);
EmptyEnabled.args = ArgsParser.flat({
    value: '',
    disabled: false,
    label: {
        text: 'Empty and Labeled',
        toolTip: 'A beautiful tooltip',
        position: 'north',
    },
});
/* assigns
{
  "value": "",
  "disabled": false,
  "label.text": "Empty and Labeled",
  "label.toolTip": "A beautiful tooltip",
  "label.position": "north"
}
*/

This results in:

enter image description here

If I modify the controls for the 3 label properties now, it won't affect the component. Also, the label in the initial state is gone.

If instead I assign the expanded model:

export const EmptyEnabled = TemplateFactory(TAG_NAME);
EmptyEnabled.args = {
    value: '',
    disabled: false,
    label: {
        text: 'Empty and Labeled',
        position: 'north',
    },
};

then I get this:

enter image description here

but when I try to use the radio buttons for label.position it doesn't affect the component, but (only after picking twice) it results in the JSON suddenly displaying undefined for position:

enter image description here

The same happens if I edit label.text and/or label.toolTip:

enter image description here

Tenebrae answered 17/9, 2021 at 8:23 Comment(5)
Hi @connexo. I run into the same problem. Did you manage somehow to find the solution for this? I've been trying out similar approach without any success...Dustin
@LazarNikolic Unfortunately, no. We have decided to live with what Storybook offers.Tenebrae
@Tenebrae Check out the answer I posted. Let me know if that works for you.Preece
@Preece where did you post it?Postmeridian
Someone should write flatten prop types Storybook addon for thatOpaline
P
4

This was my approach in an Angular project:

type InputPropOverrides = {
  'arg1.label': string,
};

export default {
  title: 'Components/MyComponent',
  component: MyComponent,
  decorators: [
    moduleMetadata({
      imports: [MyModule],
    }),
  ],
  argTypes: {
    arg1: { ... },
    'arg1.label': {
      control: {
        type: 'text',
      },
    },
  },
} as Meta;

const Template: Story<MyComponent & InputPropOverrides> = (args: MyComponent & InputPropOverrides) => {
  const updatedArgs = args;
  updatedArgs.menuItem.label = args['arg1.label'];
  return { props: updatedArgs };
};

This will render an extra control labeled arg1.label with a text field. When I input data into that field, the Story gets re-rendered with the label field gets replaced by that text.

I can manually customize any argument's property just by adding an extra argType, and passing that arg's value back to the real arg.

Preece answered 13/5, 2022 at 2:20 Comment(3)
We're not using TypeScript.Tenebrae
@Tenebrae Same concept, just remove the types.Preece
Oh jeez. That's so painful. Thanks!Bonaventura
D
1

Apparently argTypes for nested properties are not supported. But you can map custom arg to your component's property within render() function.

Here is an example for react and typescript:

type MyComponentAndCustomArgs = React.ComponentProps<typeof MyComponent> & {
  myCustomArg: string;
};

const meta = {
  title: "Example/MyComponent",
  component: MyComponent,
  tags: ["autodocs"],
  argTypes: {
    myCustomArg: { control: "text" },
  },
  render: ({ myCustomArg, ...args }) => {
    const props: MyComponentProps = { ...args };
    props.axis.color = myCustomArg;

    return <MyComponent {...props}></MyComponent>;
  },
} satisfies Meta<MyComponentAndCustomArgs>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Primary: Story = {
  args: {
    axis: {
      color: "black",
    },
    myCustomArg: "red",
  },
};
Detrimental answered 28/9, 2023 at 19:4 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.