From 5865ae87f4bac3f2d3dd5e2fc4d23bc6e8cd0c62 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 4 Oct 2022 17:09:58 +0200 Subject: [PATCH 01/10] SB-738: Sound arg types --- story.ts | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/story.ts b/story.ts index 9d62832f4af..8ecbf2fa485 100644 --- a/story.ts +++ b/story.ts @@ -126,7 +126,7 @@ export type PlayFunction = ( - update?: StoryContextUpdate + update?: StoryContextUpdate> ) => TFramework['storyResult']; // This is a passArgsFirst: false user story function @@ -168,7 +168,7 @@ export type BaseAnnotations[]; + decorators?: DecoratorFunction[]; /** * Custom metadata for a story. @@ -192,7 +192,7 @@ export type BaseAnnotations[]; + loaders?: LoaderFunction[]; /** * Define a custom render function for the story(ies). If not passed, a default render function by the framework will be used. @@ -213,10 +213,8 @@ export type ProjectAnnotations< }; type StoryDescriptor = string[] | RegExp; -export type ComponentAnnotations< - TFramework extends AnyFramework = AnyFramework, - TArgs = Args -> = BaseAnnotations & { +export interface ComponentAnnotations + extends BaseAnnotations { /** * Title of the component which will be presented in the navigation. **Should be unique.** * @@ -286,12 +284,10 @@ export type ComponentAnnotations< * By defining them each component will have its tab in the args table. */ subcomponents?: Record; -}; +} -export type StoryAnnotations< - TFramework extends AnyFramework = AnyFramework, - TArgs = Args -> = BaseAnnotations & { +export interface StoryAnnotations + extends BaseAnnotations { /** * Override the display name in the UI (CSF v3) */ @@ -309,7 +305,7 @@ export type StoryAnnotations< /** @deprecated */ story?: Omit, 'story'>; -}; +} export type LegacyAnnotatedStoryFn< TFramework extends AnyFramework = AnyFramework, From 64ee46a25b12a4be43154767c84f592dbe51f685 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 6 Oct 2022 14:53:00 +0200 Subject: [PATCH 02/10] Use HKT's instead --- story.ts | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/story.ts b/story.ts index 8ecbf2fa485..2cabfda8ad4 100644 --- a/story.ts +++ b/story.ts @@ -6,10 +6,6 @@ export type ComponentId = string; export type ComponentTitle = string; export type StoryName = string; -type A = { - field: TF['component']; -}; - /** @deprecated */ export type StoryKind = ComponentTitle; @@ -52,13 +48,18 @@ 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; + T: unknown; + storyResult: unknown; +}; + export type StoryContextForEnhancers< TFramework extends AnyFramework = AnyFramework, TArgs = Args > = StoryIdentifier & { - component?: TFramework['component']; - subcomponents?: Record; + component?: (TFramework & { T: any })['component']; + subcomponents?: (TFramework & { T: any })['component']; parameters: Parameters; initialArgs: TArgs; @@ -266,7 +267,7 @@ export interface ComponentAnnotations; } -export interface StoryAnnotations - extends BaseAnnotations { +export type StoryAnnotations< + TFramework extends AnyFramework = AnyFramework, + TArgs = Args, + TArgsAnnotations = Partial +> = BaseAnnotations & { /** * Override the display name in the UI (CSF v3) */ @@ -305,7 +309,7 @@ export interface StoryAnnotations, 'story'>; -} +} & ({} extends TArgsAnnotations ? { args?: TArgsAnnotations } : { args: TArgsAnnotations }); export type LegacyAnnotatedStoryFn< TFramework extends AnyFramework = AnyFramework, From 612a5db81362158697eae915e045e9fa733d092e Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 6 Oct 2022 15:01:12 +0200 Subject: [PATCH 03/10] update the test --- story.test.ts | 33 ++++++++++++++++++++------------- story.ts | 2 +- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/story.test.ts b/story.test.ts index a9ec9dfd203..015834bc5c8 100644 --- a/story.test.ts +++ b/story.test.ts @@ -1,22 +1,29 @@ -import { Args, ComponentAnnotations, StoryAnnotationsOrFn, ProjectAnnotations } from './story'; +import { + Args, + ComponentAnnotations, + StoryAnnotationsOrFn, + ProjectAnnotations, + AnyFramework, +} from './story'; // NOTE Example of internal type definition for @storybook/ (where X is a framework) -type XFramework = { - component: () => string; +interface XFramework extends AnyFramework { + component: (args: this['T']) => string; storyResult: string; -}; +} type XMeta = ComponentAnnotations; type XStory = StoryAnnotationsOrFn; // NOTE Examples of using types from @storybook/ 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', @@ -24,8 +31,8 @@ const simple: XMeta = { 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 = { @@ -44,7 +51,7 @@ 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 }, @@ -52,24 +59,24 @@ CSF1Story.story = { 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', + 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 }, }; const CSF3StoryStrict: XStory = { - render: args => 'Named Story', + 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' }, diff --git a/story.ts b/story.ts index 2cabfda8ad4..0bcab9592d0 100644 --- a/story.ts +++ b/story.ts @@ -267,7 +267,7 @@ export interface ComponentAnnotations Date: Thu, 6 Oct 2022 16:17:12 +0200 Subject: [PATCH 04/10] explain usage for higher kinded T --- story.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/story.ts b/story.ts index 0bcab9592d0..069a2d576e0 100644 --- a/story.ts +++ b/story.ts @@ -50,8 +50,12 @@ export type StrictGlobalTypes = { [name: string]: StrictInputType }; export type AnyFramework = { component: unknown; - T: 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< @@ -267,7 +271,7 @@ export interface ComponentAnnotations Date: Thu, 6 Oct 2022 17:35:55 +0200 Subject: [PATCH 05/10] make T optional --- story.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/story.ts b/story.ts index 069a2d576e0..9372b710249 100644 --- a/story.ts +++ b/story.ts @@ -55,7 +55,7 @@ export type AnyFramework = { // component: (args: this['T']) => string; // This generic type will eventually be filled in with TArgs // Credits to Michael Arnaldi. - T: unknown; + T?: unknown; }; export type StoryContextForEnhancers< From 8c64e8a155fd4abce7ccc4007714720a65075087 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Mon, 10 Oct 2022 14:56:32 +0200 Subject: [PATCH 06/10] rename TArgsAnnotations -> TRequiredArgs --- story.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/story.ts b/story.ts index 9372b710249..299668e90c2 100644 --- a/story.ts +++ b/story.ts @@ -294,7 +294,7 @@ export interface ComponentAnnotations + TRequiredArgs = Partial > = BaseAnnotations & { /** * Override the display name in the UI (CSF v3) @@ -313,7 +313,7 @@ export type StoryAnnotations< /** @deprecated */ story?: Omit, 'story'>; -} & ({} extends TArgsAnnotations ? { args?: TArgsAnnotations } : { args: TArgsAnnotations }); +} & ({} extends TRequiredArgs ? { args?: TRequiredArgs } : { args: TRequiredArgs }); export type LegacyAnnotatedStoryFn< TFramework extends AnyFramework = AnyFramework, From 32afba5f6e7eed2244d9ac1e9f40e74b3e6b1f19 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Mon, 10 Oct 2022 16:59:37 +0200 Subject: [PATCH 07/10] fix subcomponents regression --- story.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/story.ts b/story.ts index 299668e90c2..54e2f48b54a 100644 --- a/story.ts +++ b/story.ts @@ -63,8 +63,7 @@ export type StoryContextForEnhancers< TArgs = Args > = StoryIdentifier & { component?: (TFramework & { T: any })['component']; - subcomponents?: (TFramework & { T: any })['component']; - + subcomponents?: Record; parameters: Parameters; initialArgs: TArgs; argTypes: StrictArgTypes; From 9dcfaccea160029ca08c72162fe43e116a93ed9d Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 18 Oct 2022 10:47:50 +0200 Subject: [PATCH 08/10] ArgsFromMeta utility and generic ArgsStoryFn RT --- story.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/story.ts b/story.ts index 54e2f48b54a..1e65a82bb1f 100644 --- a/story.ts +++ b/story.ts @@ -1,3 +1,4 @@ +import { Simplify, UnionToIntersection } from 'type-fest'; import { SBType, SBScalarType } from './SBType'; export * from './SBType'; @@ -142,7 +143,7 @@ export type LegacyStoryFn = ( args: TArgs, context: StoryContext -) => TFramework['storyResult']; +) => (TFramework & { T: TArgs })['storyResult']; // This is either type of user story function export type StoryFn = @@ -332,3 +333,19 @@ export type AnnotatedStoryFn< export type StoryAnnotationsOrFn = | AnnotatedStoryFn | StoryAnnotations; + +export type ArgsFromMeta = Meta extends { + render?: ArgsStoryFn; + loaders?: (infer Loaders)[]; + decorators?: (infer Decorators)[]; +} + ? Simplify & LoaderArgs> + : unknown; + +type DecoratorsArgs = UnionToIntersection< + Decorators extends DecoratorFunction ? Args : unknown +>; + +type LoaderArgs = UnionToIntersection< + Loaders extends LoaderFunction ? Args : unknown +>; From 06e47d617994b19543f80633402636a5f484959f Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 18 Oct 2022 17:03:24 +0200 Subject: [PATCH 09/10] use tsup and upgrade dependencies --- includeConditionalArg.test.ts | 21 ++++++++------------- includeConditionalArg.ts | 3 ++- index.ts | 2 +- story.ts | 5 +++-- 4 files changed, 14 insertions(+), 17 deletions(-) diff --git a/includeConditionalArg.test.ts b/includeConditionalArg.test.ts index e3a09c89132..91688b0bc06 100644 --- a/includeConditionalArg.test.ts +++ b/includeConditionalArg.test.ts @@ -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', () => { @@ -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}"`); }); }); diff --git a/includeConditionalArg.ts b/includeConditionalArg.ts index 290ae3cbc8d..6a09061e50f 100644 --- a/includeConditionalArg.ts +++ b/includeConditionalArg.ts @@ -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, value: any) => { const { exists, eq, neq, truthy } = cond as any; @@ -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); }; diff --git a/index.ts b/index.ts index 2e357275f20..b8aa8202869 100644 --- a/index.ts +++ b/index.ts @@ -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 { diff --git a/story.ts b/story.ts index 1e65a82bb1f..1bda6104ac4 100644 --- a/story.ts +++ b/story.ts @@ -1,3 +1,4 @@ +/* global HTMLElement, AbortSignal */ import { Simplify, UnionToIntersection } from 'type-fest'; import { SBType, SBScalarType } from './SBType'; @@ -343,9 +344,9 @@ export type ArgsFromMeta = Meta extends { : unknown; type DecoratorsArgs = UnionToIntersection< - Decorators extends DecoratorFunction ? Args : unknown + Decorators extends DecoratorFunction ? TArgs : unknown >; type LoaderArgs = UnionToIntersection< - Loaders extends LoaderFunction ? Args : unknown + Loaders extends LoaderFunction ? TArgs : unknown >; From 738f4c7ca79fd7422a79e848da7c0a3629fe30be Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Wed, 19 Oct 2022 12:12:04 +0200 Subject: [PATCH 10/10] add type test for ArgsFromMeta --- story.test.ts | 43 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/story.test.ts b/story.test.ts index 015834bc5c8..baedcfd0083 100644 --- a/story.test.ts +++ b/story.test.ts @@ -1,9 +1,14 @@ +import { expectTypeOf } from 'expect-type'; import { + AnyFramework, Args, + ArgsFromMeta, + ArgsStoryFn, ComponentAnnotations, - StoryAnnotationsOrFn, + DecoratorFunction, + LoaderFunction, ProjectAnnotations, - AnyFramework, + StoryAnnotationsOrFn, } from './story'; // NOTE Example of internal type definition for @storybook/ (where X is a framework) @@ -93,7 +98,35 @@ const project: ProjectAnnotations = { }, }; -// 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 = (Story, { args }) => + `${args.decoratorArg}`; + + const decorator2: DecoratorFunction = (Story, { args }) => + `${args.decoratorArg2}`; + + const loader: LoaderFunction = async ({ args }) => ({ + loader: `${args.loaderArg}`, + }); + + const loader2: LoaderFunction = async ({ args }) => ({ + loader2: `${args.loaderArg2}`, + }); + + const renderer: ArgsStoryFn = (args) => `${args.theme}`; + + const meta = { + component: Button, + args: { disabled: false }, + render: renderer, + decorators: [decorator1, decorator2], + loaders: [loader, loader2], + }; + expectTypeOf>().toEqualTypeOf<{ + theme: string; + decoratorArg: string; + decoratorArg2: string; + loaderArg: number; + loaderArg2: number; + }>(); });