Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Vue3: Improve CSF3 types #19602

Merged
merged 5 commits into from
Oct 25, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions code/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ module.exports = {
'/examples/*/src/*/*/*.*',
// TODO: Can not get svelte-jester to work, but also not necessary for this test, as it is run by tsc/svelte-check.
'/renderers/svelte/src/public-types.test.ts',
'/renderers/vue3/src/public-types.test.ts',
],
collectCoverage: false,
collectCoverageFrom: [
Expand Down
3 changes: 3 additions & 0 deletions code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,9 @@
"window-size": "^1.1.1",
"zx": "^7.0.3"
},
"devDependencies": {
"expect-type": "^0.14.2"
},
"dependenciesMeta": {
"@compodoc/compodoc": {
"built": false
Expand Down
8 changes: 5 additions & 3 deletions code/renderers/vue3/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
"*.d.ts"
],
"scripts": {
"check": "../../../scripts/node_modules/.bin/tsc --noEmit",
"check": "vue-tsc --noEmit",
"prep": "../../../scripts/prepare/bundle.ts"
},
"dependencies": {
Expand All @@ -58,12 +58,14 @@
"global": "^4.4.0",
"react": "16.14.0",
"react-dom": "16.14.0",
"ts-dedent": "^2.0.0"
"ts-dedent": "^2.0.0",
"type-fest": "2.19.0"
},
"devDependencies": {
"@digitak/esrun": "^3.2.2",
"typescript": "~4.6.3",
"vue": "^3.0.0"
"vue": "^3.2.41",
"vue-tsc": "^1.0.8"
},
"peerDependencies": {
"@babel/core": "*",
Expand Down
16 changes: 16 additions & 0 deletions code/renderers/vue3/src/__tests__/Button.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<script setup lang="ts">
defineProps<{ disabled: boolean; label: string }>();

const emit = defineEmits<{
(e: 'myChangeEvent', id: number): void;
(e: 'myClickEvent', id: number): void;
}>();
</script>

<template>
<button :disabled="disabled" @change="emit('myChangeEvent', 0)" @click="emit('myClickEvent', 0)">
{{ label }}
</button>
</template>

<style scoped></style>
8 changes: 8 additions & 0 deletions code/renderers/vue3/src/__tests__/Decorator.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<script setup lang="ts">
defineProps<{ decoratorArg: string }>();
</script>

<template>
Decorator: {decoratorArg}
<slot></slot>
</template>
8 changes: 8 additions & 0 deletions code/renderers/vue3/src/__tests__/Decorator2.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<script setup lang="ts">
defineProps<{ decoratorArg2: string }>();
</script>

<template>
Decorator: {decoratorArg2}
<slot></slot>
</template>
2 changes: 1 addition & 1 deletion code/renderers/vue3/src/decorateStory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export function decorateStory(
): LegacyStoryFn<VueFramework> {
return decorators.reduce(
(decorated: LegacyStoryFn<VueFramework>, decorator) => (context: StoryContext<VueFramework>) => {
let story: VueFramework['storyResult'];
let story: VueFramework['storyResult'] | undefined;

const decoratedStory: VueFramework['storyResult'] = decorator((update) => {
story = decorated({ ...context, ...sanitizeStoryContextUpdate(update) });
Expand Down
192 changes: 192 additions & 0 deletions code/renderers/vue3/src/public-types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { satisfies } from '@storybook/core-common';
import { ComponentAnnotations, StoryAnnotations } from '@storybook/csf';
import { expectTypeOf } from 'expect-type';
import { SetOptional } from 'type-fest';
import { ComponentOptions, FunctionalComponent, h } from 'vue';
import Button from './__tests__/Button.vue';
import Decorator2TsVue from './__tests__/Decorator2.vue';
import DecoratorTsVue from './__tests__/Decorator.vue';
import { DecoratorFn, Meta, StoryObj } from './public-types';
import { VueFramework } from './types';

describe('Meta', () => {
test('Generic parameter of Meta can be a component', () => {
const meta: Meta<typeof Button> = {
component: Button,
args: { label: 'good', disabled: false },
};

expectTypeOf(meta).toEqualTypeOf<
ComponentAnnotations<
VueFramework,
{
readonly disabled: boolean;
readonly label: string;
onMyChangeEvent?: (id: number) => any;
onMyClickEvent?: (id: number) => any;
}
>
>();
});

test('Generic parameter of Meta can be the props of the component', () => {
const meta: Meta<{ disabled: boolean; label: string }> = {
component: Button,
args: { label: 'good', disabled: false },
};

expectTypeOf(meta).toEqualTypeOf<
ComponentAnnotations<VueFramework, { disabled: boolean; label: string }>
>();
});

test('Events are inferred from component', () => {
const meta: Meta<typeof Button> = {
component: Button,
args: {
label: 'good',
disabled: false,
onMyChangeEvent: (value) => {
expectTypeOf(value).toEqualTypeOf<number>();
},
},
render: (args) => {
return h(Button, {
...args,
onMyChangeEvent: (value) => {
expectTypeOf(value).toEqualTypeOf<number>();
},
});
},
};
});
});

describe('StoryObj', () => {
type ButtonProps = {
readonly disabled: boolean;
readonly label: string;
onMyChangeEvent?: ((id: number) => any) | undefined;
onMyClickEvent?: ((id: number) => any) | undefined;
};

test('✅ Required args may be provided partial in meta and the story', () => {
const meta = satisfies<Meta<typeof Button>>()({
component: Button,
args: { label: 'good' },
});

type Actual = StoryObj<typeof meta>;
type Expected = StoryAnnotations<VueFramework, ButtonProps, SetOptional<ButtonProps, 'label'>>;
expectTypeOf<Actual>().toEqualTypeOf<Expected>();
});

test('❌ The combined shape of meta args and story args must match the required args.', () => {
{
const meta = satisfies<Meta<typeof Button>>()({ component: Button });

type Expected = StoryAnnotations<VueFramework, ButtonProps, ButtonProps>;
expectTypeOf<StoryObj<typeof meta>>().toEqualTypeOf<Expected>();
}
{
const meta = satisfies<Meta<typeof Button>>()({
component: Button,
args: { label: 'good' },
});
// @ts-expect-error disabled not provided ❌
const Basic: StoryObj<typeof meta> = {};

type Expected = StoryAnnotations<
VueFramework,
ButtonProps,
SetOptional<ButtonProps, 'label'>
>;
expectTypeOf(Basic).toEqualTypeOf<Expected>();
}
{
const meta = satisfies<Meta<{ label: string; disabled: boolean }>>()({ component: Button });
const Basic: StoryObj<typeof meta> = {
// @ts-expect-error disabled not provided ❌
args: { label: 'good' },
};

type Expected = StoryAnnotations<VueFramework, ButtonProps, ButtonProps>;
expectTypeOf(Basic).toEqualTypeOf<Expected>();
}
});

test('Component can be used as generic parameter for StoryObj', () => {
expectTypeOf<StoryObj<typeof Button>>().toEqualTypeOf<
StoryAnnotations<VueFramework, ButtonProps>
>();
});
});

type ThemeData = 'light' | 'dark';

type ComponentProps<Component> = Component extends ComponentOptions<infer P>
? P
: Component extends FunctionalComponent<infer P>
? P
: never;

describe('Story args can be inferred', () => {
test('Correct args are inferred when type is widened for render function', () => {
type Props = ComponentProps<typeof Button> & { theme: ThemeData };

const meta = satisfies<Meta<Props>>()({
component: Button,
args: { disabled: false },
render: (args) => {
return h('div', [h('div', `Use the theme ${args.theme}`), h(Button, args)]);
},
});

const Basic: StoryObj<typeof meta> = { args: { theme: 'light', label: 'good' } };

type Expected = StoryAnnotations<VueFramework, Props, SetOptional<Props, 'disabled'>>;
expectTypeOf(Basic).toEqualTypeOf<Expected>();
});

const withDecorator: DecoratorFn<{ decoratorArg: string }> = (
storyFn,
{ args: { decoratorArg } }
) => h(DecoratorTsVue, { decoratorArg }, h(storyFn()));

test('Correct args are inferred when type is widened for decorators', () => {
type Props = ComponentProps<typeof Button> & { decoratorArg: string };

const meta = satisfies<Meta<Props>>()({
component: Button,
args: { disabled: false },
decorators: [withDecorator],
});

const Basic: StoryObj<typeof meta> = { args: { decoratorArg: 'title', label: 'good' } };

type Expected = StoryAnnotations<VueFramework, Props, SetOptional<Props, 'disabled'>>;
expectTypeOf(Basic).toEqualTypeOf<Expected>();
});

test('Correct args are inferred when type is widened for multiple decorators', () => {
type Props = ComponentProps<typeof Button> & { decoratorArg: string; decoratorArg2: string };

const secondDecorator: DecoratorFn<{ decoratorArg2: string }> = (
storyFn,
{ args: { decoratorArg2 } }
) => h(Decorator2TsVue, { decoratorArg2 }, h(storyFn()));

const meta = satisfies<Meta<Props>>()({
component: Button,
args: { disabled: false },
decorators: [withDecorator, secondDecorator],
});

const Basic: StoryObj<typeof meta> = {
args: { decoratorArg: '', decoratorArg2: '', label: 'good' },
};

type Expected = StoryAnnotations<VueFramework, Props, SetOptional<Props, 'disabled'>>;
expectTypeOf(Basic).toEqualTypeOf<Expected>();
});
});
35 changes: 33 additions & 2 deletions code/renderers/vue3/src/public-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import type {
ComponentAnnotations,
StoryAnnotations,
AnnotatedStoryFn,
ArgsStoryFn,
ArgsFromMeta,
DecoratorFunction,
} from '@storybook/csf';
import { SetOptional, Simplify } from 'type-fest';
import { ComponentOptions, ConcreteComponent, FunctionalComponent } from 'vue';
import { VueFramework } from './types';

export type { Args, ArgTypes, Parameters, StoryContext } from '@storybook/csf';
Expand All @@ -13,7 +18,9 @@ export type { Args, ArgTypes, Parameters, StoryContext } from '@storybook/csf';
*
* @see [Default export](https://storybook.js.org/docs/formats/component-story-format/#default-export)
*/
export type Meta<TArgs = Args> = ComponentAnnotations<VueFramework, TArgs>;
export type Meta<CmpOrArgs = Args> = CmpOrArgs extends ComponentOptions<infer Props>
? ComponentAnnotations<VueFramework, unknown extends Props ? CmpOrArgs : Props>
: ComponentAnnotations<VueFramework, CmpOrArgs>;

/**
* Story function that represents a CSFv2 component example.
Expand All @@ -27,11 +34,35 @@ export type StoryFn<TArgs = Args> = AnnotatedStoryFn<VueFramework, TArgs>;
*
* @see [Named Story exports](https://storybook.js.org/docs/formats/component-story-format/#named-story-exports)
*/
export type StoryObj<TArgs = Args> = StoryAnnotations<VueFramework, TArgs>;
export type StoryObj<MetaOrCmpOrArgs = Args> = MetaOrCmpOrArgs extends {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
export type StoryObj<MetaOrCmpOrArgs = Args> = MetaOrCmpOrArgs extends {
export type StoryObj<TMetaOrCmpOrArgs = Args> = MetaOrCmpOrArgs extends {

Generally we've gone with a convention that a type variable is prefaced with T to distinguish from a concrete type. WDYT?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know, not a big fan, but also not necessarily against it. I understand that it may be something useful if you want to do:

type GenericType<TComponent extends Component> = ...

But I'm also not sure if it really becomes more readable if you use it everywhere.

I'm okay with adopting it, I would have to change some stuff then in the other renderers as well :D

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shall I adopt this convention in a separate PR?

render?: ArgsStoryFn<VueFramework, any>;
component?: infer Component;
args?: infer DefaultArgs;
}
? Simplify<
ComponentProps<Component> & ArgsFromMeta<VueFramework, MetaOrCmpOrArgs>
> extends infer TArgs
? StoryAnnotations<
VueFramework,
TArgs,
SetOptional<TArgs, Extract<keyof TArgs, keyof DefaultArgs>>
>
: never
: MetaOrCmpOrArgs extends ConcreteComponent<any>
? StoryAnnotations<VueFramework, ComponentProps<MetaOrCmpOrArgs>>
: StoryAnnotations<VueFramework, MetaOrCmpOrArgs>;

type ComponentProps<Component> = Component extends ComponentOptions<infer P>
? P
: Component extends FunctionalComponent<infer P>
? P
: unknown;
/**
* @deprecated Use `StoryObj` instead.
* Story function that represents a CSFv3 component example.
*
* @see [Named Story exports](https://storybook.js.org/docs/formats/component-story-format/#named-story-exports)
*/
export type Story<TArgs = Args> = StoryObj<TArgs>;

export type DecoratorFn<TArgs = Args> = DecoratorFunction<VueFramework, TArgs>;
9 changes: 3 additions & 6 deletions code/renderers/vue3/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ export const render: ArgsStoryFn<VueFramework> = (props, context) => {
);
}

// TODO remove this hack
return h(Component as Parameters<typeof h>[0], props);
return h(Component, props);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎉 nice!!! ❤️

};

let setupFunction = (app: any) => {};
Expand All @@ -40,7 +39,7 @@ export function renderToDOM(
return h(element);
},
});
storybookApp.config.errorHandler = showException;
storybookApp.config.errorHandler = (e: unknown) => showException(e as Error);
element = storyFn();

if (!element) {
Expand All @@ -56,9 +55,7 @@ export function renderToDOM(

showMain();

if (map.has(domElement)) {
map.get(domElement).unmount();
}
map.get(domElement)?.unmount();

storybookApp.mount(domElement);
}
10 changes: 6 additions & 4 deletions code/renderers/vue3/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { StoryContext as StoryContextBase } from '@storybook/csf';
import type { AnyFramework, StoryContext as StoryContextBase } from '@storybook/csf';
import type { ConcreteComponent } from 'vue';

export type { RenderContext } from '@storybook/core-client';
Expand All @@ -12,7 +12,9 @@ export type StoryFnVueReturnType = ConcreteComponent<any>;

export type StoryContext = StoryContextBase<VueFramework>;

export type VueFramework = {
component: ConcreteComponent<any>;
export interface VueFramework extends AnyFramework {
// We are omitting props, as we don't use it internally, and more importantly, it completely changes the assignability of meta.component.
// Try not omitting, and check the type errros in the test file, if you want to learn more.
component: Omit<ConcreteComponent<this['T']>, 'props'>;
storyResult: StoryFnVueReturnType;
};
}
Loading