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

CSF: Add base factories #30416

Merged
merged 25 commits into from
Feb 4, 2025
Merged
Changes from 1 commit
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
Prev Previous commit
Next Next commit
Add type tests
kasperpeulen committed Jan 30, 2025
commit 2085d4b6434a214599d8e9f35de391efd6faf905
6 changes: 3 additions & 3 deletions code/core/src/manager/globals/exports.ts
Original file line number Diff line number Diff line change
@@ -963,9 +963,9 @@ export default {
'UPDATE_QUERY_PARAMS',
'UPDATE_STORY_ARGS',
],
'storybook/internal/types': ['Addon_TypesEnum'],
'@storybook/types': ['Addon_TypesEnum'],
'@storybook/core/types': ['Addon_TypesEnum'],
'storybook/internal/types': ['Addon_TypesEnum', 'definePreview'],
'@storybook/types': ['Addon_TypesEnum', 'definePreview'],
'@storybook/core/types': ['Addon_TypesEnum', 'definePreview'],
'storybook/internal/manager-errors': [
'Category',
'ProviderDoesNotExtendBaseProviderError',
238 changes: 234 additions & 4 deletions code/renderers/react/src/csf-factories.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,26 @@
// @vitest-environment happy-dom
// this file tests Typescript types that's why there are no assertions
import { describe, it } from 'vitest';
import { expect, test } from 'vitest';

import { Button } from './__test__/Button';
import type { KeyboardEventHandler, ReactElement, ReactNode } from 'react';
import React from 'react';

import type { Args, StrictArgs } from 'storybook/internal/types';

import type { Canvas } from '@storybook/csf';
import type { Mock } from '@storybook/test';
import { fn } from '@storybook/test';

import { expectTypeOf } from 'expect-type';

import { definePreview } from './preview';
import type { Decorator } from './public-types';

type ButtonProps = { label: string; disabled: boolean };
const Button: (props: ButtonProps) => ReactElement = () => <></>;

const preview = definePreview({});

test('csf factories', () => {
const config = definePreview({
@@ -12,13 +31,224 @@ test('csf factories', () => {
],
});

const meta = config.meta({ component: Button, args: { primary: true } });
const meta = config.meta({ component: Button, args: { disabled: true } });

const MyStory = meta.story({
args: {
children: 'Hello world',
label: 'Hello world',
},
});

expect(MyStory.input.args?.label).toBe('Hello world');
});

describe('Args can be provided in multiple ways', () => {
it('✅ All required args may be provided in meta', () => {
const meta = preview.meta({
component: Button,
args: { label: 'good', disabled: false },
});

const Basic = meta.story({});
});

it('✅ Required args may be provided partial in meta and the story', () => {
const meta = preview.meta({
component: Button,
args: { label: 'good' },
});
const Basic = meta.story({
args: { disabled: false },
});
});

it('❌ The combined shape of meta args and story args must match the required args.', () => {
{
const meta = preview.meta({ component: Button });
const Basic = meta.story({
// @ts-expect-error disabled not provided ❌
args: { label: 'good' },
});
}
{
const meta = preview.meta({
component: Button,
args: { label: 'good' },
});
// @ts-expect-error disabled not provided ❌
const Basic = meta.story({});
}
{
const meta = preview.meta({ component: Button });
const Basic = meta.story({
// @ts-expect-error disabled not provided ❌
args: { label: 'good' },
});
}
});
});

it('✅ Void functions are not changed', () => {
interface CmpProps {
label: string;
disabled: boolean;
onClick(): void;
onKeyDown: KeyboardEventHandler;
onLoading: (s: string) => ReactElement;
submitAction(): void;
}

const Cmp: (props: CmpProps) => ReactElement = () => <></>;

const meta = preview.meta({
component: Cmp,
args: { label: 'good' },
});

const Basic = meta.story({
args: {
disabled: false,
onLoading: () => <div>Loading...</div>,
onKeyDown: fn(),
onClick: fn(),
submitAction: fn(),
},
});
});

type ThemeData = 'light' | 'dark';
declare const Theme: (props: { theme: ThemeData; children?: ReactNode }) => ReactElement;

describe('Story args can be inferred', () => {
it('Correct args are inferred when type is widened for render function', () => {
const meta = preview.meta({
component: Button,
args: { disabled: false },
render: (args: ButtonProps & { theme: ThemeData }, { component }) => {
// component is not null as it is provided in meta

expect(MyStory.input.args?.children).toBe('Hello world');
const Component = component!;
return (
<Theme theme={args.theme}>
<Component {...args} />
</Theme>
);
},
});

const Basic = meta.story({ args: { theme: 'light', label: 'good' } });
});

const withDecorator: Decorator<{ decoratorArg: number }> = (Story, { args }) => (
<>
Decorator: {args.decoratorArg}
<Story args={{ decoratorArg: 0 }} />
Comment on lines +144 to +145
Copy link
Contributor

Choose a reason for hiding this comment

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

logic: Decorator is modifying args but not preserving them between Story renders, which could cause issues with state management

</>
);

it('Correct args are inferred when type is widened for decorators', () => {
const meta = preview.meta({
component: Button,
args: { disabled: false },
decorators: [withDecorator],
});

const Basic = meta.story({ args: { decoratorArg: 0, label: 'good' } });
});

it('Correct args are inferred when type is widened for multiple decorators', () => {
type Props = ButtonProps & { decoratorArg: number; decoratorArg2: string };

const secondDecorator: Decorator<{ decoratorArg2: string }> = (Story, { args }) => (
<>
Decorator: {args.decoratorArg2}
<Story />
</>
);

// decorator is not using args
const thirdDecorator: Decorator<Args> = (Story) => (
<>
<Story />
</>
);

// decorator is not using args
const fourthDecorator: Decorator<StrictArgs> = (Story) => (
<>
<Story />
</>
);

const meta = preview.meta({
component: Button,
args: { disabled: false },
decorators: [withDecorator, secondDecorator, thirdDecorator, fourthDecorator],
});

const Basic = meta.story({
args: { decoratorArg: 0, decoratorArg2: '', label: 'good' },
});
});
});

it('Components without Props can be used, issue #21768', () => {
const Component = () => <>Foo</>;
const withDecorator: Decorator = (Story) => (
<>
<Story />
</>
);

const meta = preview.meta({
component: Component,
decorators: [withDecorator],
});

const Basic = meta.story({});
});

it('Meta is broken when using discriminating types, issue #23629', () => {
type TestButtonProps = {
text: string;
} & (
| {
id?: string;
onClick?: (e: unknown, id: string | undefined) => void;
}
| {
id: string;
onClick: (e: unknown, id: string) => void;
}
);
const TestButton: React.FC<TestButtonProps> = ({ text }) => {
return <p>{text}</p>;
};

preview.meta({
title: 'Components/Button',
component: TestButton,
args: {
text: 'Button',
},
});
});

it('Infer mock function given to args in meta.', () => {
type Props = { label: string; onClick: () => void; onRender: () => JSX.Element };
const TestButton = (props: Props) => <></>;

const meta = preview.meta({
component: TestButton,
args: { label: 'label', onClick: fn(), onRender: () => <>some jsx</> },
});

const Basic = meta.story({
play: async ({ args, mount }) => {
const canvas = await mount(<TestButton {...args} />);
expectTypeOf(canvas).toEqualTypeOf<Canvas>();
expectTypeOf(args.onClick).toEqualTypeOf<Mock>();
expectTypeOf(args.onRender).toEqualTypeOf<() => JSX.Element>();
},
});
});
26 changes: 21 additions & 5 deletions code/renderers/react/src/preview.tsx
Original file line number Diff line number Diff line change
@@ -10,10 +10,10 @@ import type {
} from 'storybook/internal/types';
import { definePreview as definePreviewBase } from 'storybook/internal/types';

import type { ArgsStoryFn } from '@storybook/csf';
import type { ArgsStoryFn, DecoratorFunction, LoaderFunction, Renderer } from '@storybook/csf';

import type { AddMocks } from 'src/public-types';
import type { Exact, SetOptional } from 'type-fest';
import type { RemoveIndexSignature, SetOptional, Simplify, UnionToIntersection } from 'type-fest';

import * as reactAnnotations from './entry-preview';
import * as reactDocsAnnotations from './entry-preview-docs';
@@ -27,15 +27,31 @@ export function definePreview(preview: ReactPreview['input']) {
}

export interface ReactPreview extends Preview<ReactRenderer> {
meta<TArgs extends Args, TMetaArgs extends Exact<Partial<TArgs>, TMetaArgs>>(
meta<
TArgs extends Args,
Decorators extends DecoratorFunction<ReactRenderer, any>,
// Try to make Exact<Partial<TArgs>, TMetaArgs> work
TMetaArgs extends Partial<TArgs>,
>(
meta: {
render?: ArgsStoryFn<ReactRenderer, TArgs>;
component?: ComponentType<TArgs>;
decorators?: Decorators | Decorators[];
args?: TMetaArgs;
} & ComponentAnnotations<ReactRenderer, TArgs>
): ReactMeta<{ args: TArgs }, { args: TMetaArgs }>;
} & Omit<ComponentAnnotations<ReactRenderer, TArgs>, 'decorators'>
): ReactMeta<
{
args: Simplify<
TArgs & Simplify<RemoveIndexSignature<DecoratorsArgs<ReactRenderer, Decorators>>>
>;
},
{ args: Partial<TArgs> extends TMetaArgs ? {} : TMetaArgs }
>;
}

type DecoratorsArgs<TRenderer extends Renderer, Decorators> = UnionToIntersection<
Decorators extends DecoratorFunction<TRenderer, infer TArgs> ? TArgs : unknown
>;
Comment on lines +51 to +53
Copy link
Contributor

Choose a reason for hiding this comment

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

style: DecoratorsArgs type uses UnionToIntersection which could lead to 'never' type in some edge cases with incompatible decorator args

interface ReactMeta<
Context extends { args: Args },
MetaInput extends ComponentAnnotations<ReactRenderer>,