diff --git a/code/core/src/csf/csf-factories.ts b/code/core/src/csf/csf-factories.ts index 22af4a85f64c..345892ada527 100644 --- a/code/core/src/csf/csf-factories.ts +++ b/code/core/src/csf/csf-factories.ts @@ -204,7 +204,9 @@ function defineStory< const play = mountDestructured(this.play) || mountDestructured(testFunction) - ? async ({ context }: StoryContext) => { + ? // mount needs to be explicitly destructured + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async ({ mount, context }: StoryContext) => { await this.play?.(context); await testFunction(context); } diff --git a/code/core/src/preview-api/modules/store/csf/processCSFFile.ts b/code/core/src/preview-api/modules/store/csf/processCSFFile.ts index c9b4dcec177a..16c76589757a 100644 --- a/code/core/src/preview-api/modules/store/csf/processCSFFile.ts +++ b/code/core/src/preview-api/modules/store/csf/processCSFFile.ts @@ -49,16 +49,16 @@ export function processCSFFile( ): CSFFile { const { default: defaultExport, __namedExportsOrder, ...namedExports } = moduleExports; - const firstStory = Object.values(namedExports)[0]; - if (isStory(firstStory)) { + const factoryStory = Object.values(namedExports).find((it) => isStory(it)); + if (factoryStory) { const meta: NormalizedComponentAnnotations = - normalizeComponentAnnotations(firstStory.meta.input, title, importPath); + normalizeComponentAnnotations(factoryStory.meta.input, title, importPath); checkDisallowedParameters(meta.parameters); const csfFile: CSFFile = { meta, stories: {}, moduleExports }; Object.keys(namedExports).forEach((key) => { - if (isExportStory(key, meta)) { + if (isExportStory(key, meta) && isStory(namedExports[key])) { const story: Story = namedExports[key]; const storyMeta = normalizeStory(key, story.input as any, meta); @@ -82,7 +82,7 @@ export function processCSFFile( } }); - csfFile.projectAnnotations = firstStory.meta.preview.composed; + csfFile.projectAnnotations = factoryStory.meta.preview.composed; return csfFile; } diff --git a/code/renderers/react/src/csf-factories.test.tsx b/code/renderers/react/src/csf-factories.test.tsx index 03b0de64da0a..e8a354d8e63e 100644 --- a/code/renderers/react/src/csf-factories.test.tsx +++ b/code/renderers/react/src/csf-factories.test.tsx @@ -77,7 +77,7 @@ describe('Args can be provided in multiple ways', () => { args: { label: 'good' }, }); // @ts-expect-error disabled not provided ❌ - const Basic = meta.story({}); + const Basic = meta.story(); } { const meta = preview.meta({ component: Button }); @@ -384,3 +384,20 @@ describe('Composed getters', () => { expect(renderSpy).toHaveBeenCalled(); }); }); + +it('meta.input also contains play', () => { + const meta = preview.meta({ + /** Title, component, etc... */ + play: async ({ canvas }) => { + /** Do some common interactions */ + }, + }); + + const ExtendedInteractionsStory = meta.story({ + play: async ({ canvas, ...rest }) => { + await meta.input.play?.({ canvas, ...rest }); + + /** Do some extra interactions */ + }, + }); +}); diff --git a/code/renderers/react/src/preview.tsx b/code/renderers/react/src/preview.tsx index ebcb0ffb960e..1a2815795827 100644 --- a/code/renderers/react/src/preview.tsx +++ b/code/renderers/react/src/preview.tsx @@ -38,6 +38,7 @@ export function __definePreview[]>( preview.meta = (_input) => { const meta = defineMeta(_input); const defineStory = meta.story.bind(meta); + // @ts-expect-error internal code that is hard to type meta.story = (__input: any) => { const story = defineStory(__input); // TODO: [test-syntax] Are we sure we want this? the Component construct was for @@ -76,7 +77,19 @@ export interface ReactPreview extends Preview>> >; }, - { args: Partial extends TMetaArgs ? {} : TMetaArgs } + Omit< + ComponentAnnotations< + ReactTypes & + T & { + args: Simplify< + TArgs & Simplify>> + >; + } + >, + 'args' + > & { + args: Partial extends TMetaArgs ? {} : TMetaArgs; + } >; } @@ -95,7 +108,7 @@ export interface ReactMeta ReactTypes['storyResult']; }), >( - story?: TInput + story: TInput ): ReactStory ReactTypes['storyResult'] ? { render: TInput } : TInput>; story< @@ -108,9 +121,18 @@ export interface ReactMeta >, >( - story?: TInput + story: TInput /** @ts-expect-error hard */ ): ReactStory; + + story( + ..._args: Partial extends SetOptional< + T['args'], + keyof T['args'] & keyof MetaInput['args'] + > + ? [] + : [never] + ): ReactStory; } export interface ReactStory> diff --git a/code/renderers/vue3/src/csf-factories.test.ts b/code/renderers/vue3/src/csf-factories.test.ts new file mode 100644 index 000000000000..bb7d6cba5b48 --- /dev/null +++ b/code/renderers/vue3/src/csf-factories.test.ts @@ -0,0 +1,184 @@ +// this file tests Typescript types that's why there are no assertions +import { describe, expect, expectTypeOf, it, test } from 'vitest'; + +import type { Canvas, ComponentAnnotations, StoryAnnotations } from 'storybook/internal/types'; + +import { h } from 'vue'; + +import BaseLayout from './__tests__/BaseLayout.vue'; +import Button from './__tests__/Button.vue'; +import Decorator2TsVue from './__tests__/Decorator2.vue'; +import DecoratorTsVue from './__tests__/Decorator.vue'; +import { __definePreview } from './preview'; +import type { ComponentPropsAndSlots, Decorator, Meta, StoryObj } from './public-types'; + +type ButtonProps = ComponentPropsAndSlots; + +const preview = __definePreview({ + addons: [], +}); + +test('csf factories', () => { + const config = __definePreview({ + addons: [ + { + decorators: [], + }, + ], + }); + + const meta = config.meta({ component: Button, args: { disabled: false } }); + + const MyStory = meta.story({ + args: { + label: 'Hello world', + }, + }); + + expect(MyStory.input.args?.label).toBe('Hello world'); +}); + +describe('Meta', () => { + it('Generic parameter of Meta can be a component', () => { + const meta = preview.meta({ + component: Button, + args: { label: 'good', disabled: false }, + }); + }); + + it('Events are inferred from component', () => { + const meta = preview.meta({ + component: Button, + args: { + label: 'good', + disabled: false, + onMyChangeEvent: (value) => { + expectTypeOf(value).toMatchTypeOf(); + }, + }, + render: (args) => { + return h(Button, { + ...args, + onMyChangeEvent: (value) => { + expectTypeOf(value).toMatchTypeOf(); + }, + }); + }, + }); + }); +}); + +describe('StoryObj', () => { + it('✅ Required args may be provided partial in meta and the story', () => { + const meta = preview.meta({ + component: Button, + args: { label: 'good' }, + }); + + const Story = meta.story({ + args: { + disabled: true, + }, + }); + }); + + it('❌ The combined shape of meta args and story args must match the required args.', () => { + { + 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 }); + // @ts-expect-error disabled not provided ❌ + const Basic = meta.story({ + args: { label: 'good' }, + }); + } + }); +}); + +type ThemeData = 'light' | 'dark'; + +describe('Story args can be inferred', () => { + it('Correct args are inferred when type is widened for render function', () => { + const meta = preview.meta({ + render: (args: ButtonProps & { theme: ThemeData }) => { + return h('div', [h('div', `Use the theme ${args.theme}`), h(Button, args)]); + }, + args: { disabled: false }, + }); + + const Basic = meta.story({ args: { theme: 'light', label: 'good' } }); + }); + + const withDecorator: Decorator<{ decoratorArg: string }> = ( + storyFn, + { args: { decoratorArg } } + ) => h(DecoratorTsVue, { decoratorArg }, h(storyFn())); + + it('Correct args are inferred when type is widened for decorators', () => { + type Props = ButtonProps & { decoratorArg: string }; + + const meta = preview.meta({ + component: Button, + args: { disabled: false }, + decorators: [withDecorator], + }); + + const Basic = meta.story({ args: { decoratorArg: 'title', label: 'good' } }); + }); + + it('Correct args are inferred when type is widened for multiple decorators', () => { + type Props = ButtonProps & { + decoratorArg: string; + decoratorArg2: string; + }; + + const secondDecorator: Decorator<{ decoratorArg2: string }> = ( + storyFn, + { args: { decoratorArg2 } } + ) => h(Decorator2TsVue, { decoratorArg2 }, h(storyFn())); + + const meta = preview.meta({ + component: Button, + args: { disabled: false }, + decorators: [withDecorator, secondDecorator], + }); + + const Basic = meta.story({ + args: { decoratorArg: '', decoratorArg2: '', label: 'good' }, + }); + }); +}); + +it('Infer type of slots', () => { + const meta = preview.meta({ + component: BaseLayout, + }); + + const Basic = meta.story({ + args: { + otherProp: true, + header: ({ title }) => + h({ + components: { Button }, + template: ``, + }), + default: 'default slot', + footer: h(Button, { disabled: true, label: 'footer' }), + }, + }); +}); + +it('mount accepts a Component', () => { + const Basic: StoryObj = { + async play({ mount }) { + const canvas = await mount(Button, { props: { label: 'label', disabled: true } }); + expectTypeOf(canvas).toMatchTypeOf(); + }, + }; +}); diff --git a/code/renderers/vue3/src/index.ts b/code/renderers/vue3/src/index.ts index 09f638e2dcfb..de1471176750 100644 --- a/code/renderers/vue3/src/index.ts +++ b/code/renderers/vue3/src/index.ts @@ -3,3 +3,5 @@ import './globals'; export { setup } from './render'; export * from './public-types'; export * from './portable-stories'; + +export * from './preview'; \ No newline at end of file diff --git a/code/renderers/vue3/src/preview.tsx b/code/renderers/vue3/src/preview.tsx new file mode 100644 index 000000000000..14105b9fc546 --- /dev/null +++ b/code/renderers/vue3/src/preview.tsx @@ -0,0 +1,139 @@ +import type { + AddonTypes, + InferTypes, + Meta, + Preview, + PreviewAddon, + Story, +} from 'storybook/internal/csf'; +import { definePreview as definePreviewBase } from 'storybook/internal/csf'; +import type { + ArgsStoryFn, + ComponentAnnotations, + DecoratorFunction, + ProjectAnnotations, + Renderer, + StoryAnnotations, +} from 'storybook/internal/types'; + +import type { RemoveIndexSignature, SetOptional, Simplify, UnionToIntersection } from 'type-fest'; + +import * as vueAnnotations from './entry-preview'; +import * as vueDocsAnnotations from './entry-preview-docs'; +import { type Args, ComponentPropsAndSlots } from './public-types'; +import { VueTypes } from './types'; + +export function __definePreview[]>( + input: { addons: Addons } & ProjectAnnotations> +): VuePreview> { + const preview = definePreviewBase({ + ...input, + addons: [vueAnnotations, vueDocsAnnotations, ...(input.addons ?? [])], + }) as unknown as VuePreview>; + + return preview; +} + +type InferArgs = Simplify< + ComponentPropsAndSlots & + Simplify>> +>; + +export interface VuePreview extends Preview { + meta< + C, + Decorators extends DecoratorFunction, + // Try to make Exact, TMetaArgs> work + TMetaArgs extends Partial>, + >( + meta: { + component?: C; + args?: TMetaArgs; + decorators?: Decorators | Decorators[]; + } & Omit< + ComponentAnnotations>, + 'decorators' | 'component' | 'args' + > + ): VueMeta< + VueTypes & T & { args: InferArgs }, + Omit }>, 'args'> & { + args: {} extends TMetaArgs ? {} : TMetaArgs; + } + >; + + meta< + TArgs extends Args, + Decorators extends DecoratorFunction, + // Try to make Exact, TMetaArgs> work + TMetaArgs extends Partial, + >( + meta: { + render?: ArgsStoryFn; + args?: TMetaArgs; + decorators?: Decorators | Decorators[]; + } & Omit, 'decorators' | 'args' | 'render'> + ): VueMeta< + VueTypes & + T & { + args: Simplify< + TArgs & Simplify>> + >; + }, + Omit< + ComponentAnnotations< + VueTypes & + T & { + args: Simplify< + TArgs & Simplify>> + >; + } + >, + 'args' + > & { + args: {} extends TMetaArgs ? {} : TMetaArgs; + } + >; +} + +type DecoratorsArgs = UnionToIntersection< + Decorators extends DecoratorFunction ? TArgs : unknown +>; + +export interface VueMeta> +/** @ts-expect-error hard */ + extends Meta { + // Required args don't need to be provided when the user uses an empty render + story< + TInput extends + | (() => VueTypes['storyResult']) + | (StoryAnnotations & { + render: () => VueTypes['storyResult']; + }), + >( + story: TInput + ): VueStory VueTypes['storyResult'] ? { render: TInput } : TInput>; + + story< + TInput extends Simplify< + StoryAnnotations< + T, + T['args'], + SetOptional + > + >, + >( + story: TInput + ): VueStory; + + story( + ..._args: Partial extends SetOptional< + T['args'], + keyof T['args'] & keyof MetaInput['args'] + > + ? [] + : [never] + ): VueStory; +} + +export interface VueStory> + extends Story {} diff --git a/code/renderers/vue3/src/types.ts b/code/renderers/vue3/src/types.ts index c66e9dd5c20b..14cb5ab1838f 100644 --- a/code/renderers/vue3/src/types.ts +++ b/code/renderers/vue3/src/types.ts @@ -33,3 +33,5 @@ export interface VueRenderer extends WebRenderer { options?: { props?: Record; slots?: Record } ) => Promise; } + +export interface VueTypes extends VueRenderer {}