refactor prepareMeta and prepareStory

This commit is contained in:
Jeppe Reinhold 2023-01-13 00:00:22 +01:00
parent 3063e85ed7
commit f7f46f067d
4 changed files with 164 additions and 127 deletions

View File

@ -728,20 +728,21 @@ describe('prepareMeta', () => {
},
};
const preparedStory = prepareStory({ id, name, moduleExport }, meta, { render });
const preparedMeta = prepareMeta(meta, { render });
const preparedMeta = prepareMeta(meta, { render }, {});
expect(preparedMeta).toMatchObject({
...preparedStory,
// properties that are actually different between prepareMeta and prepareStory
moduleExport: undefined,
id: 'id--__meta',
name: '__meta',
story: '__meta',
// jest doesn't think two functions are equal, so we have assert that it's just any function
applyLoaders: expect.any(Function),
originalStoryFn: expect.any(Function),
unboundStoryFn: expect.any(Function),
undecoratedStoryFn: expect.any(Function),
});
// omitting the properties from preparedStory that are not in preparedMeta
const {
name: storyName,
story,
applyLoaders,
originalStoryFn,
unboundStoryFn,
undecoratedStoryFn,
playFunction,
...expectedPreparedMeta
} = preparedStory;
expect(preparedMeta).toMatchObject(expectedPreparedMeta);
expect(Object.keys(preparedMeta)).toHaveLength(Object.keys(expectedPreparedMeta).length);
});
});

View File

@ -19,6 +19,8 @@ import type {
StoryContextForEnhancers,
StoryContextForLoaders,
StrictArgTypes,
PreparedMeta,
ModuleExport,
} from '@storybook/types';
import { includeConditionalArg } from '@storybook/csf';
@ -49,56 +51,53 @@ export function prepareStory<TRenderer extends Renderer>(
// NOTE: in the current implementation we are doing everything once, up front, rather than doing
// anything at render time. The assumption is that as we don't load all the stories at once, this
// will have a limited cost. If this proves misguided, we can refactor it.
return prepareAnnotations(storyAnnotations, componentAnnotations, projectAnnotations);
}
export function prepareMeta<TRenderer extends Renderer>(
componentAnnotations: NormalizedComponentAnnotations<TRenderer>,
projectAnnotations: NormalizedProjectAnnotations<TRenderer>
): PreparedStory<TRenderer> {
return prepareAnnotations(undefined, componentAnnotations, projectAnnotations);
}
function prepareAnnotations<TRenderer extends Renderer>(
storyAnnotations: NormalizedStoryAnnotations<TRenderer> | undefined,
componentAnnotations: NormalizedComponentAnnotations<TRenderer>,
projectAnnotations: NormalizedProjectAnnotations<TRenderer>
): PreparedStory<TRenderer> {
// NOTE: in the current implementation we are doing everything once, up front, rather than doing
// anything at render time. The assumption is that as we don't load all the stories at once, this
// will have a limited cost. If this proves misguided, we can refactor it.
const { moduleExport, id, name } = storyAnnotations || {};
const { title } = componentAnnotations;
const tags = [...(storyAnnotations?.tags || componentAnnotations.tags || []), 'story'];
const parameters: Parameters = combineParameters(
projectAnnotations.parameters,
componentAnnotations.parameters,
storyAnnotations?.parameters
const partialAnnotations = preparePartialAnnotations(
storyAnnotations,
componentAnnotations,
projectAnnotations
);
const decorators = [
...(storyAnnotations?.decorators || []),
...(componentAnnotations.decorators || []),
...(projectAnnotations.decorators || []),
];
// Currently it is only possible to set these globally
const {
applyDecorators = defaultDecorateStory,
argTypesEnhancers = [],
argsEnhancers = [],
runStep,
} = projectAnnotations;
const loaders = [
...(projectAnnotations.loaders || []),
...(componentAnnotations.loaders || []),
...(storyAnnotations?.loaders || []),
];
const applyLoaders = async (context: StoryContextForLoaders<TRenderer>) => {
const loadResults = await Promise.all(loaders.map((loader) => loader(context)));
const loaded = Object.assign({}, ...loadResults);
return { ...context, loaded };
};
const undecoratedStoryFn: LegacyStoryFn<TRenderer> = (context: StoryContext<TRenderer>) => {
const mappedArgs = Object.entries(context.args).reduce((acc, [key, val]) => {
const mapping = context.argTypes[key]?.mapping;
acc[key] = mapping && val in mapping ? mapping[val] : val;
return acc;
}, {} as Args);
const includedArgs = Object.entries(mappedArgs).reduce((acc, [key, val]) => {
const argType = context.argTypes[key] || {};
if (includeConditionalArg(argType, mappedArgs, context.globals)) acc[key] = val;
return acc;
}, {} as Args);
const includedContext = { ...context, args: includedArgs };
const { passArgsFirst: renderTimePassArgsFirst = true } = context.parameters;
return renderTimePassArgsFirst
? (render as ArgsStoryFn<TRenderer>)(includedContext.args, includedContext)
: (render as LegacyStoryFn<TRenderer>)(includedContext);
};
// Currently it is only possible to set these globally
const { applyDecorators = defaultDecorateStory, runStep } = projectAnnotations;
const decorators = [
...(storyAnnotations?.decorators || []),
...(componentAnnotations.decorators || []),
...(projectAnnotations.decorators || []),
];
// The render function on annotations *has* to be an `ArgsStoryFn`, so when we normalize
// CSFv1/2, we use a new field called `userStoryFn` so we know that it can be a LegacyStoryFn
@ -107,8 +106,95 @@ function prepareAnnotations<TRenderer extends Renderer>(
storyAnnotations?.render ||
componentAnnotations.render ||
projectAnnotations.render;
if (!render) throw new Error(`No render function available for storyId '${id}'`);
const decoratedStoryFn = applyHooks<TRenderer>(applyDecorators)(undecoratedStoryFn, decorators);
const unboundStoryFn = (context: StoryContext<TRenderer>) => {
let finalContext: StoryContext<TRenderer> = context;
if (global.FEATURES?.argTypeTargetsV7) {
const argsByTarget = groupArgsByTarget(context);
finalContext = {
...context,
allArgs: context.args,
argsByTarget,
args: argsByTarget[NO_TARGET_NAME] || {},
};
}
return decoratedStoryFn(finalContext);
};
const play = storyAnnotations?.play || componentAnnotations.play;
const playFunction =
play &&
(async (storyContext: StoryContext<TRenderer>) => {
const playFunctionContext: PlayFunctionContext<TRenderer> = {
...storyContext,
// eslint-disable-next-line @typescript-eslint/no-shadow
step: (label: StepLabel, play: PlayFunction<TRenderer>) =>
// TODO: We know runStep is defined, we need a proper normalized annotations type
runStep!(label, play, playFunctionContext),
};
return play(playFunctionContext);
});
return {
...partialAnnotations,
moduleExport,
id,
name,
story: name,
originalStoryFn: render,
undecoratedStoryFn,
unboundStoryFn,
applyLoaders,
playFunction,
};
}
export function prepareMeta<TRenderer extends Renderer>(
componentAnnotations: NormalizedComponentAnnotations<TRenderer>,
projectAnnotations: NormalizedProjectAnnotations<TRenderer>,
moduleExport: ModuleExport
): PreparedMeta<TRenderer> {
return {
...preparePartialAnnotations(undefined, componentAnnotations, projectAnnotations),
moduleExport,
};
}
function preparePartialAnnotations<TRenderer extends Renderer>(
storyAnnotations: NormalizedStoryAnnotations<TRenderer> | undefined,
componentAnnotations: NormalizedComponentAnnotations<TRenderer>,
projectAnnotations: NormalizedProjectAnnotations<TRenderer>
): Omit<StoryContextForEnhancers<TRenderer>, 'name' | 'story'> {
// NOTE: in the current implementation we are doing everything once, up front, rather than doing
// anything at render time. The assumption is that as we don't load all the stories at once, this
// will have a limited cost. If this proves misguided, we can refactor it.
const id = storyAnnotations?.id || componentAnnotations.id;
const tags = [...(storyAnnotations?.tags || componentAnnotations.tags || []), 'story'];
const parameters: Parameters = combineParameters(
projectAnnotations.parameters,
componentAnnotations.parameters,
storyAnnotations?.parameters
);
// Currently it is only possible to set these globally
const { argTypesEnhancers = [], argsEnhancers = [] } = projectAnnotations;
// The render function on annotations *has* to be an `ArgsStoryFn`, so when we normalize
// CSFv1/2, we use a new field called `userStoryFn` so we know that it can be a LegacyStoryFn
const render =
storyAnnotations?.userStoryFn ||
storyAnnotations?.render ||
componentAnnotations.render ||
projectAnnotations.render;
if (!render) throw new Error(`No render function available for id '${id}'`);
const passedArgTypes: StrictArgTypes = combineParameters(
projectAnnotations.argTypes,
componentAnnotations.argTypes,
@ -128,11 +214,12 @@ function prepareAnnotations<TRenderer extends Renderer>(
const contextForEnhancers: StoryContextForEnhancers<TRenderer> = {
componentId: componentAnnotations.id,
title,
kind: title, // Back compat
id: id || `${componentAnnotations.id}--__meta`,
name: name || '__meta',
story: name || '__meta', // Back compat
title: componentAnnotations.title,
kind: componentAnnotations.title, // Back compat
id: storyAnnotations?.id || componentAnnotations.id,
// if there's no story name, we create a fake one since enhancers expect a name
name: storyAnnotations?.name || '__meta',
story: storyAnnotations?.name || '__meta', // Back compat
component: componentAnnotations.component,
subcomponents: componentAnnotations.subcomponents,
tags,
@ -173,7 +260,7 @@ function prepareAnnotations<TRenderer extends Renderer>(
if (!global.FEATURES?.breakingChangesV7) {
contextForEnhancers.parameters = {
...contextForEnhancers.parameters,
__id: id,
id,
globals: projectAnnotations.globals,
globalTypes: projectAnnotations.globalTypes,
args: contextForEnhancers.initialArgs,
@ -181,69 +268,7 @@ function prepareAnnotations<TRenderer extends Renderer>(
};
}
const applyLoaders = async (context: StoryContextForLoaders<TRenderer>) => {
const loadResults = await Promise.all(loaders.map((loader) => loader(context)));
const loaded = Object.assign({}, ...loadResults);
return { ...context, loaded };
};
const { name, story, ...withoutStoryIdentifiers } = contextForEnhancers;
const undecoratedStoryFn: LegacyStoryFn<TRenderer> = (context: StoryContext<TRenderer>) => {
const mappedArgs = Object.entries(context.args).reduce((acc, [key, val]) => {
const mapping = context.argTypes[key]?.mapping;
acc[key] = mapping && val in mapping ? mapping[val] : val;
return acc;
}, {} as Args);
const includedArgs = Object.entries(mappedArgs).reduce((acc, [key, val]) => {
const argType = context.argTypes[key] || {};
if (includeConditionalArg(argType, mappedArgs, context.globals)) acc[key] = val;
return acc;
}, {} as Args);
const includedContext = { ...context, args: includedArgs };
const { passArgsFirst: renderTimePassArgsFirst = true } = context.parameters;
return renderTimePassArgsFirst
? (render as ArgsStoryFn<TRenderer>)(includedContext.args, includedContext)
: (render as LegacyStoryFn<TRenderer>)(includedContext);
};
const decoratedStoryFn = applyHooks<TRenderer>(applyDecorators)(undecoratedStoryFn, decorators);
const unboundStoryFn = (context: StoryContext<TRenderer>) => {
let finalContext: StoryContext<TRenderer> = context;
if (global.FEATURES?.argTypeTargetsV7) {
const argsByTarget = groupArgsByTarget(context);
finalContext = {
...context,
allArgs: context.args,
argsByTarget,
args: argsByTarget[NO_TARGET_NAME] || {},
};
}
return decoratedStoryFn(finalContext);
};
const play = storyAnnotations?.play || componentAnnotations.play;
const playFunction =
play &&
(async (storyContext: StoryContext<TRenderer>) => {
const playFunctionContext: PlayFunctionContext<TRenderer> = {
...storyContext,
// eslint-disable-next-line @typescript-eslint/no-shadow
step: (label: StepLabel, play: PlayFunction<TRenderer>) =>
// TODO: We know runStep is defined, we need a proper normalized annotations type
runStep!(label, play, playFunctionContext),
};
return play(playFunctionContext);
});
return Object.freeze({
...contextForEnhancers,
moduleExport,
originalStoryFn: render,
undecoratedStoryFn,
unboundStoryFn,
applyLoaders,
playFunction,
});
return withoutStoryIdentifiers;
}

View File

@ -86,6 +86,13 @@ export type PreparedStory<TRenderer extends Renderer = Renderer> =
playFunction?: (context: StoryContext<TRenderer>) => Promise<void> | void;
};
export type PreparedMeta<TRenderer extends Renderer = Renderer> = Omit<
StoryContextForEnhancers<TRenderer>,
'name' | 'story'
> & {
moduleExport: ModuleExport;
};
export type BoundStory<TRenderer extends Renderer = Renderer> = PreparedStory<TRenderer> & {
storyFn: PartialStoryFn<TRenderer>;
};

View File

@ -30,7 +30,11 @@ export const useOf = (of: Of, validTypes: ResolvedModuleExport['type'][] = []) =
case 'meta': {
return {
...resolved,
preparedMeta: prepareMeta(resolved.csfFile.meta, context.projectAnnotations),
preparedMeta: prepareMeta(
resolved.csfFile.meta,
context.projectAnnotations,
resolved.csfFile.moduleExports.default
),
};
}
case 'story':