Skip to content

Commit

Permalink
improve composeStories typings
Browse files Browse the repository at this point in the history
- fix types which were unsound
- improve StoriesWithPartialProps type to filter non-story exports
  • Loading branch information
yannbf committed Jul 24, 2023
1 parent 950df76 commit 9f15aaf
Show file tree
Hide file tree
Showing 3 changed files with 73 additions and 41 deletions.
39 changes: 21 additions & 18 deletions code/lib/preview-api/src/modules/store/csf/testing-utils/index.ts
Expand Up @@ -10,7 +10,7 @@ import type {
Store_CSFExports,
StoryContext,
Parameters,
PreparedStoryFn,
ComposedStoryFn,
} from '@storybook/types';

import { HooksContext } from '../../../addons';
Expand All @@ -36,7 +36,7 @@ export function composeStory<TRenderer extends Renderer = Renderer, TArgs extend
projectAnnotations: ProjectAnnotations<TRenderer> = GLOBAL_STORYBOOK_PROJECT_ANNOTATIONS as ProjectAnnotations<TRenderer>,
defaultConfig: ProjectAnnotations<TRenderer> = {},
exportsName?: string
): PreparedStoryFn<TRenderer, Partial<TArgs>> {
): ComposedStoryFn<TRenderer, Partial<TArgs>> {
if (storyAnnotations === undefined) {
throw new Error('Expected a story but received undefined.');
}
Expand Down Expand Up @@ -73,22 +73,25 @@ export function composeStory<TRenderer extends Renderer = Renderer, TArgs extend

const defaultGlobals = getValuesFromArgTypes(projectAnnotations.globalTypes);

const composedStory = (extraArgs: Partial<TArgs>) => {
const context: Partial<StoryContext> = {
...story,
hooks: new HooksContext(),
globals: defaultGlobals,
args: { ...story.initialArgs, ...extraArgs },
};

return story.unboundStoryFn(prepareContext(context as StoryContext));
};

composedStory.storyName = storyName;
composedStory.args = story.initialArgs as Partial<TArgs>;
composedStory.play = story.playFunction as ComposedStoryPlayFn<TRenderer, Partial<TArgs>>;
composedStory.parameters = story.parameters as Parameters;
composedStory.id = story.id;
const composedStory: ComposedStoryFn<TRenderer, Partial<TArgs>> = Object.assign(
(extraArgs?: Partial<TArgs>) => {
const context: Partial<StoryContext> = {
...story,
hooks: new HooksContext(),
globals: defaultGlobals,
args: { ...story.initialArgs, ...extraArgs },
};

return story.unboundStoryFn(prepareContext(context as StoryContext));
},
{
storyName,
args: story.initialArgs as Partial<TArgs>,
play: story.playFunction as ComposedStoryPlayFn<TRenderer, Partial<TArgs>>,
parameters: story.parameters as Parameters,
id: story.id,
}
);

return composedStory;
}
Expand Down
69 changes: 49 additions & 20 deletions code/lib/types/src/modules/composedStory.ts
Expand Up @@ -22,6 +22,15 @@ export type Store_CSFExports<TRenderer extends Renderer = Renderer, TArgs extend
__namedExportsOrder?: string[];
};

/**
* Type for the play function returned by a composed story, which will contain everything needed in the context,
* except the canvasElement, which should be passed by the user.
* It's useful for scenarios where the user wants to execute the play function in test environments, e.g.
*
* const { PrimaryButton } = composeStories(stories)
* const { container } = render(<PrimaryButton />) // or PrimaryButton()
* PrimaryButton.play({ canvasElement: container })
*/
export type ComposedStoryPlayContext<TRenderer extends Renderer = Renderer, TArgs = Args> = Partial<
StoryContext<TRenderer, TArgs> & Pick<StoryContext<TRenderer, TArgs>, 'canvasElement'>
>;
Expand All @@ -30,40 +39,60 @@ export type ComposedStoryPlayFn<TRenderer extends Renderer = Renderer, TArgs = A
context: ComposedStoryPlayContext<TRenderer, TArgs>
) => Promise<void> | void;

export type PreparedStoryFn<TRenderer extends Renderer = Renderer, TArgs = Args> = AnnotatedStoryFn<
TRenderer,
TArgs
> & { play: ComposedStoryPlayFn<TRenderer, TArgs>; args: TArgs; id: StoryId };
/**
* A loosely annotated story function, used internally by composeStory
*/
export type LooselyAnnotatedStoryFn<TRenderer extends Renderer = Renderer, TArgs = Args> = (
args?: TArgs
) => (TRenderer & {
T: TArgs;
})['storyResult'];

/**
* A story that got recomposed for portable stories, containing all the necessary data to be rendered in external environments
*/
export type ComposedStoryFn<
TRenderer extends Renderer = Renderer,
TArgs = Args
> = LooselyAnnotatedStoryFn<TRenderer, TArgs> & {
play: ComposedStoryPlayFn<TRenderer, TArgs>;
args: TArgs;
id: StoryId;
storyName: string;
parameters: Parameters;
};

export type ComposedStory<TRenderer extends Renderer = Renderer, TArgs = Args> =
/**
* Type that matches whether the story is a CSF2 story or a CSF3 story, used for assertion and inference
*/
export type StoryLike<TRenderer extends Renderer = Renderer, TArgs = Args> =
| StoryFn<TRenderer, TArgs>
| StoryAnnotations<TRenderer, TArgs>;

/**
* T represents the whole ES module of a stories file. K of T means named exports (basically the Story type)
* 1. pick the keys K of T that have properties that are Story<AnyProps>
* 2. infer the actual prop type for each Story
* 3. reconstruct Story with Partial. Story<Props> -> Story<Partial<Props>>
* Based on a module of stories, it returns all stories within it, filtering non-stories
* Each story will have partial props, as their props should be handled when composing stories
*/
export type StoriesWithPartialProps<TRenderer extends Renderer, TModule> = {
// @TODO once we can use Typescript 4.0 do this to exclude nonStory exports:
// replace [K in keyof TModule] with [K in keyof TModule as TModule[K] extends ComposedStory<any> ? K : never]
[K in keyof TModule]: TModule[K] extends ComposedStory<infer _, infer TProps>
? PreparedStoryFn<TRenderer, Partial<TProps>>
// T represents the whole ES module of a stories file. K of T means named exports (basically the Story type)
// 1. pick the keys K of T that have properties that are Story<AnyProps>
// 2. infer the actual prop type for each Story
// 3. reconstruct Story with Partial. Story<Props> -> Story<Partial<Props>>
[K in keyof TModule as TModule[K] extends StoryLike<infer _, infer _TProps>
? K
: never]: TModule[K] extends StoryLike<infer _, infer TProps>
? ComposedStoryFn<TRenderer, Partial<TProps>>
: unknown;
};

/**
* Type used for integrators of portable stories, as reference when creating their own composeStory function
*/
export interface ComposeStoryFn<TRenderer extends Renderer = Renderer, TArgs extends Args = Args> {
(
storyAnnotations: AnnotatedStoryFn<TRenderer, TArgs> | StoryAnnotations<TRenderer, TArgs>,
componentAnnotations: ComponentAnnotations<TRenderer, TArgs>,
projectAnnotations: ProjectAnnotations<TRenderer>,
exportsName?: string
): {
(extraArgs: Partial<TArgs>): TRenderer['storyResult'];
storyName: string;
args: Args;
play: ComposedStoryPlayFn<TRenderer, TArgs>;
parameters: Parameters;
};
): ComposedStoryFn;
}
6 changes: 3 additions & 3 deletions code/renderers/react/src/testing-api.ts
Expand Up @@ -6,7 +6,7 @@ import {
import type {
Args,
ProjectAnnotations,
ComposedStory,
StoryLike,
Store_CSFExports,
StoriesWithPartialProps,
} from '@storybook/types';
Expand Down Expand Up @@ -81,13 +81,13 @@ const defaultProjectAnnotations: ProjectAnnotations<ReactRenderer> = {
* @param [exportsName] - in case your story does not contain a name and you want it to have a name.
*/
export function composeStory<TArgs extends Args = Args>(
story: ComposedStory<ReactRenderer, TArgs>,
story: StoryLike<ReactRenderer, TArgs>,
componentAnnotations: Meta<TArgs | any>,
projectAnnotations?: ProjectAnnotations<ReactRenderer>,
exportsName?: string
) {
return originalComposeStory<ReactRenderer, TArgs>(
story as ComposedStory<ReactRenderer, Args>,
story as StoryLike<ReactRenderer, Args>,
componentAnnotations,
projectAnnotations,
defaultProjectAnnotations,
Expand Down

0 comments on commit 9f15aaf

Please sign in to comment.