nuxt-modules / storybook

Storybook integration with Nuxt.
https://storybook.nuxtjs.org
403 stars 93 forks source link

Allow pages to be added to stories. #159

Open dankellett opened 3 years ago

dankellett commented 3 years ago

Is your feature request related to a problem? Please describe.

We often use storybook stories to allow users to see built out pages using components.

Describe the solution you'd like

Allow pages in the Nuxt Pages directory to be added to stories.

Describe alternatives you've considered

Make copies of the pages as components.

Additional context

bissolli commented 3 years ago

Guys, I am also trying to find a way to expose my page component but no success till now.

I've posted it in the Discord channel, any updates I will share it here =)

Hey Guys, I am trying to add my pages components to Storybook as I have my design system as well, and I am wondering how to accomplish that! Do you guys have any working example? The issues that I am currently facing are:

  • How can I mock the data used by my Vuex? I see that it's available with initial data, but I would need to add some mock and even better to let user update it through the ControlPanel
  • It act as if my plugins doesn't exists. For example, I have a repository in place that I access through the NuxtContext from the composition api const { $repository } => useContext() but inside of storybook it says Cannot read property '$repository' of undefined - maybe it would be an issue with @nuxjs/composition-api and not the plugin itself?
  • Also, for some weird reason, everything I expose to the template from the setup method, is actually not available in the template

Good to mention that I am using latest version of everything (nuxt, storybook, composition-api) and also TypeScript.

Any help/hint is appreciated

bissolli commented 3 years ago

Guys, any idea on that?!

To be able to add my pages to Storybook I was thinking of having 2 components to compose a page like described below:

/pages/basket/Main.vue --> not exposed to storybook and the main file loaded by my router system

<template>
  <!-- Probably not much template will be left for this component -->
  <my-main-inner ...many-props />
</template>

<script>
// all the logic that cannot be handled by Storybook
// this will do all the needed communication with the MainInner through props and events
</script>

/pages/basket/MainInner.vue --> exposed to storybook and only called by the Main.vue component

<template>
  <div>Whatever I have to do here</div>
</template>

<script>
// This will be a 'dump-component' and all the this that cannot be handled by Storybook
// will be injected from the `Main.vue`
</script>

Any feedback on it?

xinbenlv commented 3 years ago

Trying to do the same, but could not have luck.

I also tried adding to nuxt.config.js

  storybook: {
    stories: [
      '../../components/**/*.stories.@(ts|js)',
      '../../pages/**/*.stories.@(ts|js)']
  }

It doesn't help.

Rigo-m commented 3 years ago

Any feedback on it?

Having a Props-based Page-like component that gets hydrated by storybook stories with mock-data and by nuxt's page component with real data is my go-to strategy as well. I think it's clean, it works well and it's straightforward. Did you follow up with it? I'm going to implement it in a project real soon

blocka commented 3 years ago

Guys, any idea on that?!

To be able to add my pages to Storybook I was thinking of having 2 components to compose a page like described below:

/pages/basket/Main.vue --> not exposed to storybook and the main file loaded by my router system

<template>
  <!-- Probably not much template will be left for this component -->
  <my-main-inner ...many-props />
</template>

<script>
// all the logic that cannot be handled by Storybook
// this will do all the needed communication with the MainInner through props and events
</script>

/pages/basket/MainInner.vue --> exposed to storybook and only called by the Main.vue component

<template>
  <div>Whatever I have to do here</div>
</template>

<script>
// This will be a 'dump-component' and all the this that cannot be handled by Storybook
// will be injected from the `Main.vue`
</script>

Any feedback on it?

The naming stinks Main and MainInner :(

Rigo-m commented 3 years ago

The naming stinks Main and MainInner :(

IndexPage.vue -> Page component Index.vue -> route

BlogPostPage.vue -> Page component blog/_post.vue -> route

My "page components" live inside the components/pages directory, each page component accept a pageData prop, and each page component gets called by one route only. This way I can hydrate the pageData prop both by a story and from the actual data that I gather via asyncData or fetch inside the nuxt route component. Let me know if this sounds good to you guys

blocka commented 3 years ago

What about the layout? Or you're just fine with stopping at the page.

I once tried to go down the rabbit hole of trying to figure out how to render the App.js file generated by nuxt in storybook, but didn't get very far.

Rigo-m commented 3 years ago

What do you mean by layout?

And why would you render the App.js file? Storybook is meant to validate and showcase your components/pages, it shouldn't be able to tinker with your entire application.

blocka commented 3 years ago

In my regular vue applications (that i'd use for things like admin sections, etc.) I have top-level stories in which I mock the router and show the entire page as it would be rendered in the browser (so you'd see the header, menus, etc., in addition to whatever that route would render)

Rigo-m commented 3 years ago

Which, in my opinion, should be a page component + a global decorator with whatever the layout has (e.g: menu, footer etc)

blocka commented 3 years ago

what do you do in the decorator...mock the component so that it shows the given page?

Rigo-m commented 3 years ago

I decorate the page with whatever component would be in the default.vue layout

blocka commented 3 years ago

Not sure I understand you:

That component usually looks like

<div><Nuxt/></div>

how exactly are you using it to "decorate"?

Rigo-m commented 3 years ago

My layout: <div> <Menu/> <Nuxt /> <Footer /></div> My route (let's say, index.vue): <IndexPage /> My page component (IndexPage.vue): <div> <ComponentA /> <ComponentB /></div>

My IndexPage story:

import IndexPage from '~/components/IndexPage'

// Mimics the layout behaviour
const layoutDecorator = (story) => ({
  components: { story },
  template: `
    <div> <Menu/> <story /> <Footer /></div>
  `
})

export default {
  title: 'IndexPage',
  decorators: [layoutDecorator]
}

const Template = (args, { argTypes }) => ({
  components: IndexPage,
  template: '<IndexPage />'
})

export const Page = Template.bind({})
blocka commented 3 years ago

ok...but you're duplicating your whole layout in the story...

Rigo-m commented 3 years ago

Yes. But as I said, layout behaviour should be separate from stories. Stories needs to be agnostic and introducing a ubercharged dynamic component like the <Nuxt /> component is bad practice in my opinion. Also, you can export the layoutDecorator from a decorators.js file and import it in your page's stories. So you will only write layoutDecorator once

blocka commented 3 years ago

My suggestion was have the decorator replace the component with , basically...not to use it as is

So you will only write layoutDecorator once

I don't mean copying the layoutDecorator code...I mean duplicating the layout itself...if you change the layout code you'd have to change the decorator.

I suppose a potential way around that is to make "yet another" component for your layout which accepts a slot

so

// layouts/default.vue
<DefaultLayout><Nuxt /></DefaultLayout>
// components/layouts/default-layout.vue
<div><Menu/><slot /><Footer /></div>
// decarator 
const layoutDecorator = (story) => ({
  components: { story, DefaultLayout },
  template: `
    <DefaultLayout> <story /> <DefaultLayout>
  `
})

still, I miss the ability to just tell storybook "render me whatever is at router /foo/bar/23"

Rigo-m commented 3 years ago

Your solution is better.

If you want to render whatever is at router /foo/bar/23 run the nuxt sever 😅 If you want to use storybook as it's meant to be used (with mocked data, component statuses etc) the way we paved in the comments above is the right way IMHO.

blocka commented 3 years ago

If you want to render whatever is at router /foo/bar/23 run the nuxt sever

without talking to a server...all data mocked with msw.

Rigo-m commented 3 years ago

Then again, I stand by my point. Using components composition and building stories on top of agnostic components (that can both work alone and inside nuxt supercharged components) is the way to go.

tyom commented 3 years ago

I ended up with the solution based on @blocka's suggestion. I created a global decorator with the layout name-to-component map. As long as I declare the page component in the story's component prop it should get the layout value from the page component and map it to the appropriate layout component.

// preview.js
const layoutDecorator = (story, { parameters }) => {
  const layouts = {
    'my-custom': () => import('~/components/layout/MyCustomlayout.vue'),
  }
  const layoutFallbackComponent = {
    template: '<div class="layout-not-found"><slot /></div>',
  }
  const pageLayout = parameters.component?.options?.layout
  const LayoutComponent = layouts[pageLayout] ?? layoutFallbackComponent
  return {
    components: { story, LayoutComponent },
    template: '<LayoutComponent><story /></LayoutComponent>',
  }
}
export const decorators = [layoutDecorator]
// some.story.js
export default {
  title: 'pages/some-page',
  component: SomePage,
}

The downside is that you still have to maintain the layout map, but at least it's a simple key/value and could probably be extracted from the layouts directory by reading the raw files to go a step beyond.

ishikawa-yuya commented 3 years ago

I could simulate pages on storybook to replace render() of nuxt component with a function which returns default slot of layout component.

/** .storybook/preview.js */

import Vue from 'vue'

Vue.component('nuxt', {
  render() {
    // this.$root.$children[0].$children[0] is <layout-component />
    return this.$root.$children[0].$children[0].$slots.default
  }
})

export * from '../.nuxt-storybook/storybook/preview.js'

/** pages/foo/bar.stories.ts */

import { defineComponent, useRouter } from '@nuxtjs/composition-api'

import PageComponent from '@/pages/foo/bar.vue'
const LayoutComponent = require(`@/layouts/${PageComponent.layout}.vue`).default

export default {
  title: 'pages/foo/bar'
  component: PageComponent
}

export const FooBar = () => defineComponent({
  components: { LayoutComponent, PageComponent },
  template: '<layout-component><page-component /><layout-component>'
})

But I could not deal with pages which can be shown after login process because a redirection after login avoided to display an expected pages.

I ended up with the solution not to use slot of layout component but to make a transition to target pages with router.push().

/** .storybook/preview.js */

export * from '../.nuxt-storybook/storybook/preview.js'

/** pages/foo/bar.stories.ts */

import { defineComponent, useRouter } from '@nuxtjs/composition-api'

import PageComponent from '@/pages/foo/bar.vue'
const LayoutComponent = require(`@/layouts/${PageComponent.layout}.vue`).default

export default {
  title: 'pages/foo/bar'
  component: PageComponent
}

export const FooBar = () => defineComponent({
    components: { LayoutComponent },
    template: '<layout-component v-if="!fetchState.pending" />'
    setup: () => {
      const router = useRouter()
      const { $auth } = useContext()
      const { fetch, fetchState } = useFetch(async () => {
        await $auth.logout()
        await $auth.loginWith('local', {userId: 'xxx', password: 'xxx'}) // I use msw to simulate mock api server.
        router.push({ path: '/foo/bar', query:  { id: '1' } })
      })
      fetch()
      return { fetchState }
    }
})
GMartigny commented 2 years ago

Thanks to the @ishikawa-yuya-functional-inc comment above, I kinda manage to add pages to my storybook. However, I made a small change to the render function:

import Vue from 'vue';
// My auth pages layout
import auth from '@/layouts/auth';

// Override the Nuxt component
Vue.component('Nuxt', {
  render() {
    return this.$parent.$slots.default;
  },
});

export default {
  title: 'Pages/Login',
  decorators: [() => ({
    components: {
      auth,
    },
    // Surround stories with the layout
    template: '<auth><story /></auth>',
  })],
};