Skip to content

Commit

Permalink
Merge pull request #19603 from storybookjs/future/CSF3-vue2
Browse files Browse the repository at this point in the history
Vue2: Improve CSF3 types
  • Loading branch information
kasperpeulen committed Oct 25, 2022
2 parents ec0f15f + 4e6ed75 commit 065bd82
Show file tree
Hide file tree
Showing 12 changed files with 328 additions and 67 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/vue/src/public-types.test.ts',
'/renderers/vue3/src/public-types.test.ts',
],
collectCoverage: false,
Expand Down
8 changes: 5 additions & 3 deletions code/renderers/vue/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 @@ -59,11 +59,13 @@
"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": {
"typescript": "~4.6.3",
"vue": "^2.6.12"
"vue": "2.6.14",
"vue-tsc": "^1.0.9"
},
"peerDependencies": {
"@babel/core": "*",
Expand Down
28 changes: 28 additions & 0 deletions code/renderers/vue/src/__tests__/Button.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<template>
<button type="button" class="classes" @click="onClick" :disabled="disabled">{{ label }}</button>
</template>

<script lang="ts">
import Vue from 'vue';
import './button.css';
export default Vue.extend({
name: 'my-button',
props: {
label: {
type: String,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
},
methods: {
onClick(): void {
this.$emit('onClick');
},
},
});
</script>
10 changes: 5 additions & 5 deletions code/renderers/vue/src/decorateStory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const WRAPS = 'STORYBOOK_WRAPS';

function prepare(
rawStory: StoryFnVueReturnType,
innerStory?: VueConstructor,
innerStory?: StoryFnVueReturnType,
context?: StoryContext<VueFramework>
): VueConstructor | null {
let story: ComponentOptions<Vue> | VueConstructor;
Expand Down Expand Up @@ -63,10 +63,10 @@ function prepare(
export function decorateStory(
storyFn: LegacyStoryFn<VueFramework>,
decorators: DecoratorFunction<VueFramework>[]
): LegacyStoryFn<VueFramework> {
) {
return decorators.reduce(
(decorated: LegacyStoryFn<VueFramework>, decorator) => (context: StoryContext<VueFramework>) => {
let story;
let story: VueFramework['storyResult'] | undefined;

const decoratedStory = decorator((update) => {
story = decorated({ ...context, ...sanitizeStoryContextUpdate(update) });
Expand All @@ -81,10 +81,10 @@ export function decorateStory(
return story;
}

return prepare(decoratedStory, story as any);
return prepare(decoratedStory, story) as VueFramework['storyResult'];
},
(context) => {
return prepare(storyFn(context), null, context);
return prepare(storyFn(context), undefined, context) as VueFramework['storyResult'];
}
);
}
7 changes: 4 additions & 3 deletions code/renderers/vue/src/docs/sourceDecorator.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint no-underscore-dangle: ["error", { "allow": ["_vnode"] }] */

import { ComponentOptions } from 'vue';
import type { ComponentOptions, VueConstructor } from 'vue';
import Vue from 'vue/dist/vue';
import { vnodeToString } from './sourceDecorator';

Expand All @@ -10,12 +10,13 @@ expect.addSnapshotSerializer({
});

const getVNode = (Component: ComponentOptions<any, any, any>) => {
const vm = new Vue({
render(h: (c: any) => unknown) {
const vm = new (Vue as unknown as VueConstructor)({
render(h) {
return h(Component);
},
}).$mount();

// @ts-expect-error TS says it is called $vnode
return vm.$children[0]._vnode;
};

Expand Down
10 changes: 6 additions & 4 deletions code/renderers/vue/src/docs/sourceDecorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@

import { addons } from '@storybook/addons';
import { logger } from '@storybook/client-logger';
import type Vue from 'vue';

import { SourceType, SNIPPET_RENDERED } from '@storybook/docs-tools';
import type { ComponentOptions } from 'vue';
import type Vue from 'vue';
import type { StoryContext } from '../types';

export const skipSourceRender = (context: StoryContext) => {
Expand Down Expand Up @@ -43,13 +43,15 @@ export const sourceDecorator = (storyFn: any, context: StoryContext) => {
// lifecycle hook.
mounted() {
// Theoretically this does not happens but we need to check it.
// @ts-expect-error TS says it is called $vnode
if (!this._vnode) {
return;
}

try {
const storyNode = lookupStoryInstance(this, storyComponent);

// @ts-expect-error TS says it is called $vnode
const code = vnodeToString(storyNode._vnode);

channel.emit(SNIPPET_RENDERED, (context || {}).id, `<template>${code}</template>`, 'vue');
Expand All @@ -58,7 +60,7 @@ export const sourceDecorator = (storyFn: any, context: StoryContext) => {
}
},
template: '<story />',
};
} as ComponentOptions<Vue> & ThisType<Vue>;
};

export function vnodeToString(vnode: Vue.VNode): string {
Expand All @@ -69,7 +71,7 @@ export function vnodeToString(vnode: Vue.VNode): string {
...(vnode.data?.attrs ? Object.entries(vnode.data.attrs) : []),
]
.filter(([name], index, list) => list.findIndex((item) => item[0] === name) === index)
.map(([name, value]) => stringifyAttr(name, value))
.map(([name, value]) => stringifyAttr(name!, value))
.filter(Boolean)
.join(' ');

Expand Down
178 changes: 178 additions & 0 deletions code/renderers/vue/src/public-types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { satisfies } from '@storybook/core-common';
import { ComponentAnnotations, StoryAnnotations } from '@storybook/csf';
import { expectTypeOf } from 'expect-type';
import { SetOptional } from 'type-fest';
import { Component } from 'vue';
import { ExtendedVue, Vue } from 'vue/types/vue';
import { DecoratorFn, Meta, StoryObj } from './public-types';
import Button from './__tests__/Button.vue';
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,
{
disabled: boolean;
label: string;
}
>
>();
});

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 }>
>();
});
});

describe('StoryObj', () => {
type ButtonProps = {
disabled: boolean;
label: string;
};

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<C> = C extends ExtendedVue<any, any, any, any, infer P>
? P
: C extends Component<infer P>
? P
: unknown;

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) =>
Vue.extend({
components: { Button },
template: `<div>Using the theme: ${args.theme}<Button v-bind="$props"/></div>`,
props: Object.keys(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 } }
) =>
Vue.extend({
components: { Story: storyFn() },
template: `<div>Decorator: ${decoratorArg}<Story/></div>`,
});

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 } }
) => {
return Vue.extend({
components: { Story: storyFn() },
template: `<div>Decorator: ${decoratorArg2}<Story/></div>`,
});
};

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>();
});
});
Loading

0 comments on commit 065bd82

Please sign in to comment.