Skip to content

Commit

Permalink
Merge pull request #48 from ComponentDriven/add-step
Browse files Browse the repository at this point in the history
Add step to play context and `runStep` to project annotations
  • Loading branch information
shilman authored Oct 22, 2022
2 parents bf7e1e8 + d262cd9 commit e952787
Show file tree
Hide file tree
Showing 5 changed files with 139 additions and 50 deletions.
21 changes: 8 additions & 13 deletions includeConditionalArg.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/ban-ts-ignore */
import { includeConditionalArg, testValue } from './includeConditionalArg';
import type { Conditional } from './story';

describe('testValue', () => {
describe('truthy', () => {
Expand Down Expand Up @@ -60,33 +61,27 @@ describe('testValue', () => {
describe('includeConditionalArg', () => {
describe('errors', () => {
it('should throw if neither arg nor global is specified', () => {
expect(() => includeConditionalArg({ if: {} }, {}, {})).toThrowErrorMatchingInlineSnapshot(
`"Invalid conditional value {}"`
);
expect(() =>
includeConditionalArg({ if: {} as Conditional }, {}, {})
).toThrowErrorMatchingInlineSnapshot(`"Invalid conditional value {}"`);
});
it('should throw if arg and global are both specified', () => {
expect(() =>
includeConditionalArg({ if: { arg: 'a', global: 'b' } }, {}, {})
).toThrowErrorMatchingInlineSnapshot(
`"Invalid conditional value {\\"arg\\":\\"a\\",\\"global\\":\\"b\\"}"`
);
).toThrowErrorMatchingInlineSnapshot(`"Invalid conditional value {"arg":"a","global":"b"}"`);
});
it('should throw if mulitiple exists / eq / neq are specified', () => {
expect(() =>
includeConditionalArg({ if: { arg: 'a', exists: true, eq: 1 } }, {}, {})
).toThrowErrorMatchingInlineSnapshot(
`"Invalid conditional test {\\"exists\\":true,\\"eq\\":1}"`
);
).toThrowErrorMatchingInlineSnapshot(`"Invalid conditional test {"exists":true,"eq":1}"`);

expect(() =>
includeConditionalArg({ if: { arg: 'a', exists: false, neq: 0 } }, {}, {})
).toThrowErrorMatchingInlineSnapshot(
`"Invalid conditional test {\\"exists\\":false,\\"neq\\":0}"`
);
).toThrowErrorMatchingInlineSnapshot(`"Invalid conditional test {"exists":false,"neq":0}"`);

expect(() =>
includeConditionalArg({ if: { arg: 'a', eq: 1, neq: 0 } }, {}, {})
).toThrowErrorMatchingInlineSnapshot(`"Invalid conditional test {\\"eq\\":1,\\"neq\\":0}"`);
).toThrowErrorMatchingInlineSnapshot(`"Invalid conditional test {"eq":1,"neq":0}"`);
});
});

Expand Down
3 changes: 2 additions & 1 deletion includeConditionalArg.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import isEqual from 'lodash/isEqual';
import { Args, Globals, InputType, Conditional } from './story';

const count = (vals: any[]) => vals.map(v => typeof v !== 'undefined').filter(Boolean).length;
const count = (vals: any[]) => vals.map((v) => typeof v !== 'undefined').filter(Boolean).length;

export const testValue = (cond: Omit<Conditional, 'arg' | 'global'>, value: any) => {
const { exists, eq, neq, truthy } = cond as any;
Expand Down Expand Up @@ -35,5 +35,6 @@ export const includeConditionalArg = (argType: InputType, args: Args, globals: G
}

const value = arg ? args[arg] : globals[global];
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return testValue(argType.if!, value);
};
2 changes: 1 addition & 1 deletion index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export interface SeparatorOptions {
*/
export const parseKind = (kind: string, { rootSeparator, groupSeparator }: SeparatorOptions) => {
const [root, remainder] = kind.split(rootSeparator, 2);
const groups = (remainder || kind).split(groupSeparator).filter(i => !!i);
const groups = (remainder || kind).split(groupSeparator).filter((i) => !!i);

// when there's no remainder, it means the root wasn't found/split
return {
Expand Down
79 changes: 65 additions & 14 deletions story.test.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,43 @@
import { Args, ComponentAnnotations, StoryAnnotationsOrFn } from './story';
import { expectTypeOf } from 'expect-type';
import {
AnyFramework,
Args,
ArgsFromMeta,
ArgsStoryFn,
ComponentAnnotations,
DecoratorFunction,
LoaderFunction,
ProjectAnnotations,
StoryAnnotationsOrFn,
} from './story';

// NOTE Example of internal type definition for @storybook/<X> (where X is a framework)
type XFramework = {
component: () => string;
interface XFramework extends AnyFramework {
component: (args: this['T']) => string;
storyResult: string;
};
}

type XMeta<TArgs = Args> = ComponentAnnotations<XFramework, TArgs>;
type XStory<TArgs = Args> = StoryAnnotationsOrFn<XFramework, TArgs>;

// NOTE Examples of using types from @storybook/<X> in real project

const Button: XFramework['component'] = () => 'Button';
type ButtonArgs = {
x: string;
y: string;
};

const Button = (props: ButtonArgs) => 'Button';

// NOTE Various kind usages
const simple: XMeta = {
title: 'simple',
component: Button,
decorators: [(storyFn, context) => `withDecorator(${storyFn(context)})`],
parameters: { a: () => null, b: NaN, c: Symbol('symbol') },
loaders: [() => Promise.resolve({ d: '3' })],
args: { a: 1 },
argTypes: { a: { type: { name: 'string' } } },
args: { x: '1' },
argTypes: { x: { type: { name: 'string' } } },
};

const strict: XMeta<ButtonArgs> = {
Expand All @@ -44,23 +56,23 @@ const Simple: XStory = () => 'Simple';
const CSF1Story: XStory = () => 'Named Story';
CSF1Story.story = {
name: 'Another name for story',
decorators: [storyFn => `Wrapped(${storyFn()}`],
decorators: [(storyFn) => `Wrapped(${storyFn()}`],
parameters: { a: [1, '2', {}], b: undefined, c: Button },
loaders: [() => Promise.resolve({ d: '3' })],
args: { a: 1 },
};

const CSF2Story: XStory = () => 'Named Story';
CSF2Story.storyName = 'Another name for story';
CSF2Story.decorators = [storyFn => `Wrapped(${storyFn()}`];
CSF2Story.decorators = [(storyFn) => `Wrapped(${storyFn()}`];
CSF2Story.parameters = { a: [1, '2', {}], b: undefined, c: Button };
CSF2Story.loaders = [() => Promise.resolve({ d: '3' })];
CSF2Story.args = { a: 1 };

const CSF3Story: XStory = {
render: (args) => 'Named Story',
name: 'Another name for story',
decorators: [storyFn => `Wrapped(${storyFn()}`],
decorators: [(storyFn) => `Wrapped(${storyFn()}`],
parameters: { a: [1, '2', {}], b: undefined, c: Button },
loaders: [() => Promise.resolve({ d: '3' })],
args: { a: 1 },
Expand All @@ -69,13 +81,52 @@ const CSF3Story: XStory = {
const CSF3StoryStrict: XStory<ButtonArgs> = {
render: (args) => 'Named Story',
name: 'Another name for story',
decorators: [storyFn => `Wrapped(${storyFn()}`],
decorators: [(storyFn) => `Wrapped(${storyFn()}`],
parameters: { a: [1, '2', {}], b: undefined, c: Button },
loaders: [() => Promise.resolve({ d: '3' })],
args: { x: '1' },
play: async ({ step }) => {
await step('a step', async ({ step: substep }) => {
await substep('a substep', () => {});
});
},
};

const project: ProjectAnnotations<XFramework> = {
async runStep(label, play, context) {
return play(context);
},
};

// NOTE Jest forced to define at least one test in file
describe('story', () => {
test('true', () => expect(true).toBe(true));
test('ArgsFromMeta will infer correct args from render/loader/decorators', () => {
const decorator1: DecoratorFunction<XFramework, { decoratorArg: string }> = (Story, { args }) =>
`${args.decoratorArg}`;

const decorator2: DecoratorFunction<XFramework, { decoratorArg2: string }> = (Story, { args }) =>
`${args.decoratorArg2}`;

const loader: LoaderFunction<XFramework, { loaderArg: number }> = async ({ args }) => ({
loader: `${args.loaderArg}`,
});

const loader2: LoaderFunction<XFramework, { loaderArg2: number }> = async ({ args }) => ({
loader2: `${args.loaderArg2}`,
});

const renderer: ArgsStoryFn<XFramework, { theme: string }> = (args) => `${args.theme}`;

const meta = {
component: Button,
args: { disabled: false },
render: renderer,
decorators: [decorator1, decorator2],
loaders: [loader, loader2],
};
expectTypeOf<ArgsFromMeta<XFramework, typeof meta>>().toEqualTypeOf<{
theme: string;
decoratorArg: string;
decoratorArg2: string;
loaderArg: number;
loaderArg2: number;
}>();
});
84 changes: 63 additions & 21 deletions story.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
/* global HTMLElement, AbortSignal */
import { Simplify, UnionToIntersection } from 'type-fest';
import { SBType, SBScalarType } from './SBType';

export * from './SBType';
Expand All @@ -6,10 +8,6 @@ export type ComponentId = string;
export type ComponentTitle = string;
export type StoryName = string;

type A<TF extends AnyFramework> = {
field: TF['component'];
};

/** @deprecated */
export type StoryKind = ComponentTitle;

Expand Down Expand Up @@ -52,14 +50,22 @@ export type Globals = { [name: string]: any };
export type GlobalTypes = { [name: string]: InputType };
export type StrictGlobalTypes = { [name: string]: StrictInputType };

export type AnyFramework = { component: unknown; storyResult: unknown };
export type AnyFramework = {
component: unknown;
storyResult: unknown;
// A generic type T that can be used in the definition of the component like this:
// component: (args: this['T']) => string;
// This generic type will eventually be filled in with TArgs
// Credits to Michael Arnaldi.
T?: unknown;
};

export type StoryContextForEnhancers<
TFramework extends AnyFramework = AnyFramework,
TArgs = Args
> = StoryIdentifier & {
component?: TFramework['component'];
subcomponents?: Record<string, TFramework['component']>;

component?: (TFramework & { T: any })['component'];
subcomponents?: Record<string, (TFramework & { T: any })['component']>;
parameters: Parameters;
initialArgs: TArgs;
argTypes: StrictArgTypes<TArgs>;
Expand Down Expand Up @@ -106,13 +112,27 @@ export type StoryContext<
canvasElement: HTMLElement;
};

export type StepLabel = string;

export type StepFunction<TFramework extends AnyFramework = AnyFramework, TArgs = Args> = (
label: StepLabel,
play: PlayFunction<TFramework, TArgs>
) => Promise<void> | void;

export type PlayFunctionContext<
TFramework extends AnyFramework = AnyFramework,
TArgs = Args
> = StoryContext<TFramework, TArgs> & {
step: StepFunction<TFramework, TArgs>;
};

export type PlayFunction<TFramework extends AnyFramework = AnyFramework, TArgs = Args> = (
context: StoryContext<TFramework, TArgs>
context: PlayFunctionContext<TFramework, TArgs>
) => Promise<void> | void;

// This is the type of story function passed to a decorator -- does not rely on being passed any context
export type PartialStoryFn<TFramework extends AnyFramework = AnyFramework, TArgs = Args> = (
update?: StoryContextUpdate<TArgs>
update?: StoryContextUpdate<Partial<TArgs>>
) => TFramework['storyResult'];

// This is a passArgsFirst: false user story function
Expand All @@ -124,7 +144,7 @@ export type LegacyStoryFn<TFramework extends AnyFramework = AnyFramework, TArgs
export type ArgsStoryFn<TFramework extends AnyFramework = AnyFramework, TArgs = Args> = (
args: TArgs,
context: StoryContext<TFramework, TArgs>
) => TFramework['storyResult'];
) => (TFramework & { T: TArgs })['storyResult'];

// This is either type of user story function
export type StoryFn<TFramework extends AnyFramework = AnyFramework, TArgs = Args> =
Expand All @@ -141,14 +161,20 @@ export type DecoratorApplicator<TFramework extends AnyFramework = AnyFramework,
decorators: DecoratorFunction<TFramework, TArgs>[]
) => LegacyStoryFn<TFramework, TArgs>;

export type StepRunner<TFramework extends AnyFramework = AnyFramework, TArgs = Args> = (
label: StepLabel,
play: PlayFunction<TFramework, TArgs>,
context: PlayFunctionContext<TFramework, TArgs>
) => Promise<void>;

export type BaseAnnotations<TFramework extends AnyFramework = AnyFramework, TArgs = Args> = {
/**
* Wrapper components or Storybook decorators that wrap a story.
*
* Decorators defined in Meta will be applied to every story variation.
* @see [Decorators](https://storybook.js.org/docs/addons/introduction/#1-decorators)
*/
decorators?: DecoratorFunction<TFramework, Args>[];
decorators?: DecoratorFunction<TFramework, TArgs>[];

/**
* Custom metadata for a story.
Expand All @@ -172,7 +198,7 @@ export type BaseAnnotations<TFramework extends AnyFramework = AnyFramework, TArg
* Asynchronous functions which provide data for a story.
* @see [Loaders](https://storybook.js.org/docs/react/writing-stories/loaders)
*/
loaders?: LoaderFunction<TFramework, Args>[];
loaders?: LoaderFunction<TFramework, TArgs>[];

/**
* Define a custom render function for the story(ies). If not passed, a default render function by the framework will be used.
Expand All @@ -189,13 +215,12 @@ export type ProjectAnnotations<
globals?: Globals;
globalTypes?: GlobalTypes;
applyDecorators?: DecoratorApplicator<TFramework, Args>;
runStep?: StepRunner<TFramework, TArgs>;
};

type StoryDescriptor = string[] | RegExp;
export type ComponentAnnotations<
TFramework extends AnyFramework = AnyFramework,
TArgs = Args
> = BaseAnnotations<TFramework, TArgs> & {
export interface ComponentAnnotations<TFramework extends AnyFramework = AnyFramework, TArgs = Args>
extends BaseAnnotations<TFramework, TArgs> {
/**
* Title of the component which will be presented in the navigation. **Should be unique.**
*
Expand Down Expand Up @@ -247,7 +272,7 @@ export type ComponentAnnotations<
*
* Used by addons for automatic prop table generation and display of other component metadata.
*/
component?: TFramework['component'];
component?: (TFramework & { T: Args extends TArgs ? any : TArgs })['component'];

/**
* Auxiliary subcomponents that are part of the stories.
Expand All @@ -265,11 +290,12 @@ export type ComponentAnnotations<
* By defining them each component will have its tab in the args table.
*/
subcomponents?: Record<string, TFramework['component']>;
};
}

export type StoryAnnotations<
TFramework extends AnyFramework = AnyFramework,
TArgs = Args
TArgs = Args,
TRequiredArgs = Partial<TArgs>
> = BaseAnnotations<TFramework, TArgs> & {
/**
* Override the display name in the UI (CSF v3)
Expand All @@ -288,7 +314,7 @@ export type StoryAnnotations<

/** @deprecated */
story?: Omit<StoryAnnotations<TFramework, TArgs>, 'story'>;
};
} & ({} extends TRequiredArgs ? { args?: TRequiredArgs } : { args: TRequiredArgs });

export type LegacyAnnotatedStoryFn<
TFramework extends AnyFramework = AnyFramework,
Expand All @@ -308,3 +334,19 @@ export type AnnotatedStoryFn<
export type StoryAnnotationsOrFn<TFramework extends AnyFramework = AnyFramework, TArgs = Args> =
| AnnotatedStoryFn<TFramework, TArgs>
| StoryAnnotations<TFramework, TArgs>;

export type ArgsFromMeta<TFramework extends AnyFramework, Meta> = Meta extends {
render?: ArgsStoryFn<TFramework, infer RArgs>;
loaders?: (infer Loaders)[];
decorators?: (infer Decorators)[];
}
? Simplify<RArgs & DecoratorsArgs<TFramework, Decorators> & LoaderArgs<TFramework, Loaders>>
: unknown;

type DecoratorsArgs<TFramework extends AnyFramework, Decorators> = UnionToIntersection<
Decorators extends DecoratorFunction<TFramework, infer TArgs> ? TArgs : unknown
>;

type LoaderArgs<TFramework extends AnyFramework, Loaders> = UnionToIntersection<
Loaders extends LoaderFunction<TFramework, infer TArgs> ? TArgs : unknown
>;

0 comments on commit e952787

Please sign in to comment.