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

[Storybook] update Storybook utils to ensure merging of control configs #7611

Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
19 changes: 10 additions & 9 deletions .storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,15 +130,16 @@ const preview: Preview = {
viewports: MINIMAL_VIEWPORTS,
},
},
// Due to CommonProps, these props appear on almost every Story, but generally
// aren't super useful to test - let's disable them by default and (if needed)
// individual stories can re-enable them, e.g. by passing
// `argTypes: { 'data-test-subj': { table: { disable: false } } }`
argTypes: hideStorybookControls<CommonProps>([
'css',
'className',
'data-test-subj',
]),
};

// Due to CommonProps, these props appear on almost every Story, but generally
// aren't super useful to test - let's disable them by default and (if needed)
// individual stories can re-enable them, e.g. by passing
// `argTypes: { 'data-test-subj': { table: { disable: false } } }`
hideStorybookControls<CommonProps>(preview, [
'css',
'className',
'data-test-subj',
]);

export default preview;
168 changes: 141 additions & 27 deletions .storybook/utils.test.ts
mgadewoll marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -13,74 +13,188 @@ import {
} from './utils';

describe('hideStorybookControls', () => {
it('outputs the expected `argTypes` object when passed prop name strings', () => {
it('updates the provided config with the expected `argTypes` object when passed prop name strings', () => {
expect(
hideStorybookControls(['isDisabled', 'isLoading', 'isInvalid'])
hideStorybookControls({ argTypes: {} }, [
'isDisabled',
'isLoading',
'isInvalid',
])
).toEqual({
isDisabled: { table: { disable: true } },
isLoading: { table: { disable: true } },
isInvalid: { table: { disable: true } },
argTypes: {
isDisabled: { table: { disable: true } },
isLoading: { table: { disable: true } },
isInvalid: { table: { disable: true } },
},
});
});

it('merges existing and new `argTypes` objects correctly', () => {
expect(
hideStorybookControls(
{
argTypes: {
isDisabled: {
control: { type: 'boolean' },
table: { category: 'Additional' },
},
},
},
['isDisabled']
)
).toEqual({
argTypes: {
isDisabled: {
control: { type: 'boolean' },
table: { category: 'Additional', disable: true },
},
},
});
});

it('throws a typescript error if a generic is passed and the prop names do not match', () => {
type TestComponentProps = { hello: boolean; world: boolean };

// No typescript error
hideStorybookControls<TestComponentProps>(['hello', 'world']);
// @ts-expect-error - will fail `yarn lint` if a TS error is *not* produced
hideStorybookControls<TestComponentProps>(['hello', 'world', 'error']);
hideStorybookControls<TestComponentProps>({ argTypes: {} }, [
'hello',
'world',
]);
hideStorybookControls<TestComponentProps>({ argTypes: {} }, [
'hello',
'world',
// @ts-expect-error - will fail `yarn lint` if a TS error is *not* produced
'error',
]);
});
});

describe('disableStorybookControls', () => {
it('outputs the expected `argTypes` object when passed prop name strings', () => {
it('updates the provided config with the expected `argTypes` object when passed prop name strings', () => {
expect(
disableStorybookControls({ argTypes: {} }, [
'isDisabled',
'isLoading',
'isInvalid',
])
).toEqual({
argTypes: {
isDisabled: { control: false },
isLoading: { control: false },
isInvalid: { control: false },
},
});
});

it('merges existing and new `argTypes` objects correctly', () => {
expect(
disableStorybookControls(['isDisabled', 'isLoading', 'isInvalid'])
disableStorybookControls(
{
argTypes: {
isDisabled: {
control: { type: 'boolean' },
table: { category: 'Additional' },
},
},
},
['isDisabled']
)
).toEqual({
isDisabled: { control: false },
isLoading: { control: false },
isInvalid: { control: false },
argTypes: {
isDisabled: {
table: { category: 'Additional' },
control: false,
},
},
});
});

it('throws a typescript error if a generic is passed and the prop names do not match', () => {
type TestComponentProps = { hello: boolean; world: boolean };

// No typescript error
disableStorybookControls<TestComponentProps>(['hello', 'world']);
// @ts-expect-error - will fail `yarn lint` if a TS error is *not* produced
disableStorybookControls<TestComponentProps>(['hello', 'world', 'error']);
disableStorybookControls<TestComponentProps>({ argTypes: {} }, [
'hello',
'world',
]);
disableStorybookControls<TestComponentProps>({ argTypes: {} }, [
'hello',
'world',
// @ts-expect-error - will fail `yarn lint` if a TS error is *not* produced
'error',
]);
});
});

describe('moveStorybookControlsToCategory', () => {
it('outputs expected `argTypes` object when passed prop name strings and a custom category', () => {
it('updates the provided config with the expected `argTypes` object when passed prop name strings and a custom category', () => {
expect(
moveStorybookControlsToCategory(
{ argTypes: {} },
['isDisabled', 'isLoading', 'isInvalid'],
'New category'
)
).toEqual({
isDisabled: { table: { category: 'New category' } },
isLoading: { table: { category: 'New category' } },
isInvalid: { table: { category: 'New category' } },
argTypes: {
isDisabled: { table: { category: 'New category' } },
isLoading: { table: { category: 'New category' } },
isInvalid: { table: { category: 'New category' } },
},
});
});

it('sets a default category if none is passed', () => {
expect(
moveStorybookControlsToCategory(['isDisabled', 'isLoading', 'isInvalid'])
moveStorybookControlsToCategory({ argTypes: {} }, [
'isDisabled',
'isLoading',
'isInvalid',
])
).toEqual({
isDisabled: { table: { category: 'Additional' } },
isLoading: { table: { category: 'Additional' } },
isInvalid: { table: { category: 'Additional' } },
argTypes: {
isDisabled: { table: { category: 'Additional' } },
isLoading: { table: { category: 'Additional' } },
isInvalid: { table: { category: 'Additional' } },
},
});
});

it('merges existing and new `argTypes` objects correctly', () => {
expect(
moveStorybookControlsToCategory(
{
argTypes: {
isDisabled: {
control: { type: 'boolean' },
table: { disable: true },
},
},
},
['isDisabled']
)
).toEqual({
argTypes: {
isDisabled: {
control: { type: 'boolean' },
table: { disable: true, category: 'Additional' },
},
},
});
});

it('throws a typescript error if a generic is passed and the prop names do not match', () => {
type TestProps = { hello: boolean; world: boolean };

// No typescript error
moveStorybookControlsToCategory<TestProps>(['hello', 'world']);
// @ts-expect-error - will fail `yarn lint` if a TS error is *not* produced
moveStorybookControlsToCategory<TestProps>(['hello', 'world', 'error']);
moveStorybookControlsToCategory<TestProps>({ argTypes: {} }, [
'hello',
'world',
]);
moveStorybookControlsToCategory<TestProps>({ argTypes: {} }, [
'hello',
'world',
// @ts-expect-error - will fail `yarn lint` if a TS error is *not* produced
'error',
]);
});
});
103 changes: 82 additions & 21 deletions .storybook/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,52 +10,75 @@
* argTypes configurations
*/

import type { Args, ArgTypes, Meta, Preview, StoryObj } from '@storybook/react';

type StorybookConfig<T> = Meta<T> | StoryObj<T> | Preview;

/**
* Completely hide props from Storybook's controls panel.
* Should be passed or spread to `argTypes`
*
* Can be used for preview (Preview), component (Meta) or story (Story)
* context by passing the config object for either. Use after defining
* the specific config to be able to pass the config to this util.
*
* @returns the mutated config
mgadewoll marked this conversation as resolved.
Show resolved Hide resolved
*/
export const hideStorybookControls = <Props>(
config: StorybookConfig<Props>,
propNames: Array<keyof Props>
): Record<keyof Props, typeof HIDE_CONTROL> | {} => {
return propNames.reduce(
(obj, name) => ({ ...obj, [name]: HIDE_CONTROL }),
{}
);
): StorybookConfig<Props> => {
const updatedConfig = _updateArgTypes(config, propNames, {
key: 'table',
value: { disable: true },
});

return updatedConfig;
};
const HIDE_CONTROL = { table: { disable: true } };

/**
* Leave props visible in Storybook's controls panel, but disable them
* from being controllable (renders a `-`).
*
* Should be passed or spread to `argTypes`
* Can be used for preview (Preview), component (Meta) or story (Story)
* context by passing the config object for either. Use after defining
* the specific config to be able to pass the config to this util.
*
* @returns the mutated config
*/
export const disableStorybookControls = <Props>(
config: StorybookConfig<Props>,
propNames: Array<keyof Props>
): Record<keyof Props, typeof DISABLE_CONTROL> | {} => {
return propNames.reduce(
(obj, name) => ({ ...obj, [name]: DISABLE_CONTROL }),
{}
);
): StorybookConfig<Props> => {
const updatedConfig = _updateArgTypes(config, propNames, {
key: 'control',
value: false,
});

return updatedConfig;
};
const DISABLE_CONTROL = { control: false };

/**
* Configure provided args to be listed under a specified
* category in the props table.
*
* Should be passed or spread to `argTypes`
* Can be used for preview (Preview), component (Meta) or story (Story)
* context by passing the config object for either. Use after defining
* the specific config to be able to pass the config to this util.
*
* @returns the mutated config
*/
export const moveStorybookControlsToCategory = <Props>(
config: StorybookConfig<Props>,
propNames: Array<keyof Props>,
category = 'Additional'
): Record<keyof Props, ControlCategory> | {} => {
return propNames.reduce(
(obj, name) => ({ ...obj, [name]: { table: { category } } }),
{}
);
): StorybookConfig<Props> => {
const updatedConfig = _updateArgTypes(config, propNames, {
key: 'table',
value: { category },
});

return updatedConfig;
};
type ControlCategory = { table: { category: string } };

/**
* parameters configurations
Expand All @@ -79,3 +102,41 @@ export const hideAllStorybookControls = {
export const hidePanel = {
options: { showPanel: false },
};

/**
* Internal helper function to merge `argTypes` objects into
* a Storybook story config object
*
* @returns the mutated config
*/
const _updateArgTypes = <Props>(
config: StorybookConfig<Props>,
propNames: Array<keyof Props>,
{ key, value }: { key: string; value: Record<string, any> | boolean | string }
mgadewoll marked this conversation as resolved.
Show resolved Hide resolved
): StorybookConfig<Props> => {
if (!Array.isArray(propNames)) return config;
mgadewoll marked this conversation as resolved.
Show resolved Hide resolved

const currentArgTypes = config.argTypes as Partial<ArgTypes<Props>>;
const newArgTypes = { ...currentArgTypes };

for (const propName of propNames) {
mgadewoll marked this conversation as resolved.
Show resolved Hide resolved
const currentArgTypeValue = newArgTypes?.[propName] ?? ({} as Args);
const currentControlValue = currentArgTypeValue.hasOwnProperty(key)
? currentArgTypeValue[key]
: ({} as Record<string, any>);

const newValue =
typeof value === 'object' && typeof currentArgTypeValue[key] === 'object'
? { ...currentControlValue, ...value }
: value;

newArgTypes[propName] = {
...currentArgTypeValue,
[key]: newValue,
};
}

config.argTypes = newArgTypes;

return config;
};