Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Core: Add 'mapping' to support complex arg values #14100

Merged
merged 8 commits into from
Mar 3, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions addons/controls/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"@storybook/addons": "6.2.0-beta.6",
"@storybook/api": "6.2.0-beta.6",
"@storybook/client-api": "6.2.0-beta.6",
"@storybook/client-logger": "6.2.0-beta.6",
"@storybook/components": "6.2.0-beta.6",
"@storybook/node-logger": "6.2.0-beta.6",
"@storybook/theming": "6.2.0-beta.6",
Expand Down
20 changes: 18 additions & 2 deletions addons/controls/src/ControlsPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import React, { FC } from 'react';
import { ArgsTable, NoControlsWarning } from '@storybook/components';
import React, { FC, useEffect } from 'react';
import dedent from 'ts-dedent';
import { useArgs, useArgTypes, useParameter } from '@storybook/api';
import { once } from '@storybook/client-logger';
import { ArgsTable, NoControlsWarning } from '@storybook/components';

import { PARAM_KEY } from './constants';

Expand All @@ -18,6 +20,20 @@ export const ControlsPanel: FC = () => {
{}
);

useEffect(() => {
if (
Object.values(rows).some(({ control: { options = {} } = {} }) =>
Object.values(options).some((v) => !['boolean', 'number', 'string'].includes(typeof v))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ghengeveld after upgrading to latest beta I'm getting this error: Uncaught TypeError: _ref$control is null

)
) {
once.warn(dedent`
Only primitives are supported as values in control options. Use a 'mapping' for complex values.

More info: https://storybook.js.org/docs/react/writing-stories/args#mapping-to-complex-arg-values
`);
}
}, [rows]);

const hasControls = Object.values(rows).some((arg) => arg?.control);
const showWarning = !(hasControls && isArgsStory) && !hideNoControlsWarning;

Expand Down
31 changes: 31 additions & 0 deletions docs/writing-stories/args.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,37 @@ The `args` param is always a set of `key:value` pairs delimited with a semicolon

Args specified through the URL will extend and override any default values of args specified on the story.

## Mapping to complex arg values

Complex values such as JSX elements cannot be serialized to the manager (e.g. the Controls addon) or synced with the URL. To work around this limitation, arg values can be "mapped" from a simple string to a complex type using the `mapping` property in `argTypes`. This works on any type of arg, but makes most sense when used with the 'select' control.

```
argTypes: {
label: {
control: {
type: 'select',
options: ['Normal', 'Bold', 'Italic']
},
mapping: {
Bold: <b>Bold</b>,
Italic: <i>Italic</i>
}
}
}
```

Note that `mapping` does not have to be exhaustive. If the arg value is not a property of `mapping`, the value will be used directly. Keys in `mapping` always correspond to arg *values*, even when `options` is an object. Specifying `options` as an object (key-value pairs) is useful if you want to use special characters in the input label. For example:
ghengeveld marked this conversation as resolved.
Show resolved Hide resolved

```
{
control: {
type: 'select',
options: { да: 'yes', нет: 'no' }
},
mapping: { yes: 'да', no: 'нет' }
}
ghengeveld marked this conversation as resolved.
Show resolved Hide resolved
```

<details>
<summary>Using args in addons</summary>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,15 @@ export default {
title: 'Addons/Controls',
component: Button,
argTypes: {
children: { control: 'text', name: 'Children' },
children: { control: 'text', name: 'Children', mapping: { basic: 'BASIC' } },
type: { control: 'text', name: 'Type' },
json: { control: 'object', name: 'JSON' },
imageUrls: { control: { type: 'file', accept: '.png' }, name: 'Image Urls' },
label: {
name: 'Label',
control: { type: 'select', options: ['Plain', 'Bold'] },
mapping: { Bold: <b>Bold</b> },
},
},
parameters: { chromatic: { disable: true } },
};
Expand All @@ -17,7 +22,7 @@ const DEFAULT_NESTED_OBJECT = { a: 4, b: { c: 'hello', d: [1, 2, 3] } };

const Template = (args) => (
<div>
<Button type={args.type}>{args.children}</Button>
<Button type={args.type}>{args.label || args.children}</Button>
{args.json && <pre>{JSON.stringify(args.json, null, 2)}</pre>}
</div>
);
Expand Down
18 changes: 18 additions & 0 deletions lib/client-api/src/story_store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,24 @@ describe('preview.story_store', () => {
);
});

it('mapping changes arg values that are passed to the story in the context', () => {
const storyFn = jest.fn();
const store = new StoryStore({ channel });
addStoryToStore(store, 'a', '1', storyFn, {
argTypes: {
one: { mapping: { 1: 'mapped' } },
two: { mapping: { 1: 'no match' } },
},
args: { one: 1, two: 2, three: 3 },
});
store.getRawStory('a', '1').storyFn();

expect(storyFn).toHaveBeenCalledWith(
{ one: 'mapped', two: 2, three: 3 },
expect.objectContaining({ args: { one: 'mapped', two: 2, three: 3 } })
);
});

it('updateStoryArgs emits STORY_ARGS_UPDATED', () => {
const onArgsChangedChannel = jest.fn();
const testChannel = mockChannel();
Expand Down
13 changes: 11 additions & 2 deletions lib/client-api/src/story_store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,8 +383,17 @@ export default class StoryStore {
const loaders = [...this._globalMetadata.loaders, ...kindMetadata.loaders, ...storyLoaders];

const finalStoryFn = (context: StoryContext) => {
const { passArgsFirst = true } = context.parameters;
return passArgsFirst ? (original as ArgsStoryFn)(context.args, context) : original(context);
const { args = {}, argTypes = {}, parameters } = context;
const { passArgsFirst = true } = parameters;
const mapped = {
...context,
args: Object.entries(args).reduce((acc, [key, val]) => {
const { mapping } = argTypes[key] || {};
acc[key] = mapping && val in mapping ? mapping[val] : val;
return acc;
}, {} as Args),
};
return passArgsFirst ? (original as ArgsStoryFn)(mapped.args, mapped) : original(mapped);
};

// lazily decorate the story when it's loaded
Expand Down
9 changes: 6 additions & 3 deletions lib/core-client/src/preview/parseArgsParam.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import qs from 'qs';
import dedent from 'ts-dedent';
import { Args } from '@storybook/addons';
import { once } from '@storybook/client-logger';
import isPlainObject from 'lodash/isPlainObject';
Expand Down Expand Up @@ -34,9 +35,11 @@ export const parseArgsParam = (argsString: string): Args => {
const parts = argsString.split(';').map((part) => part.replace('=', '~').replace(':', '='));
return Object.entries(qs.parse(parts.join(';'), QS_OPTIONS)).reduce((acc, [key, value]) => {
if (validateArgs(key, value)) return Object.assign(acc, { [key]: value });
once.warn(
'Omitted potentially unsafe URL args.\n\nMore info: https://storybook.js.org/docs/react/writing-stories/args#setting-args-through-the-url'
);
once.warn(dedent`
Omitted potentially unsafe URL args.

More info: https://storybook.js.org/docs/react/writing-stories/args#setting-args-through-the-url
`);
return acc;
}, {} as Args);
};
3 changes: 2 additions & 1 deletion lib/router/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@
"global": "^4.4.0",
"lodash": "^4.17.20",
"memoizerific": "^1.11.3",
"qs": "^6.9.5"
"qs": "^6.9.5",
"ts-dedent": "^2.0.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0",
Expand Down
9 changes: 6 additions & 3 deletions lib/router/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import deepEqual from 'fast-deep-equal';
import isPlainObject from 'lodash/isPlainObject';
import memoize from 'memoizerific';
import qs from 'qs';
import dedent from 'ts-dedent';

export interface StoryData {
viewMode?: string;
Expand Down Expand Up @@ -93,9 +94,11 @@ export const buildArgsParam = (initialArgs: Args, args: Args): string => {

const object = Object.entries(update).reduce((acc, [key, value]) => {
if (validateArgs(key, value)) return Object.assign(acc, { [key]: value });
once.warn(
'Omitted potentially unsafe URL args.\n\nMore info: https://storybook.js.org/docs/react/writing-stories/args#setting-args-through-the-url'
);
once.warn(dedent`
Omitted potentially unsafe URL args.

More info: https://storybook.js.org/docs/react/writing-stories/args#setting-args-through-the-url
`);
return acc;
}, {} as Args);

Expand Down