Skip to content

Commit

Permalink
Merge pull request #19602 from storybookjs/future/CSF3-vue3
Browse files Browse the repository at this point in the history
Vue3: Improve CSF3 types
  • Loading branch information
kasperpeulen authored Oct 25, 2022
2 parents 969b166 + c9aa816 commit 7bcf944
Show file tree
Hide file tree
Showing 13 changed files with 496 additions and 21 deletions.
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 {
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);
};

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

0 comments on commit 7bcf944

Please sign in to comment.