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

Viewed 6271

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

1 Answers

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.

Related