VulcanJS / Vulcan

🌋 A toolkit to quickly build apps with React, GraphQL & Meteor
http://vulcanjs.org
MIT License
7.96k stars 1.89k forks source link

Vulcan + Storybook Discussion #1939

Closed jefflau closed 5 years ago

jefflau commented 6 years ago

Problem

Storybook does not understand how to parse the meteor npm imports.

Possible Solution

After some playing around I managed to get it to work if I extract all Vulcan packages and higher order components that contain queries into a container component and the 'dumb' components just contain the UI. This component has no UI components inside it and it passes the Vulcan components to its children. This allows you to import the non-container components into Storybook and pass it mock functions and Components that are not from Vulcan as props into your dumb components.

The container components will not be imported into your stories. It gets a little more complicated with nested components as you need to mock even more components and then pass them through.

Thoughts

Components have to be more verbose and have separate files for each. Must avoid imports from Meteor/Vulcan in your storybook tested components. However promotes better testing practice as all testable components needs to be pure(ish), they can't even have a single Meteor import, so promotes separation of concerns of your components to container vs UI components.

Vulcan's registerComponent makes it quite easy to mock the components as all of the components you need to pass as prop are contained under one object. Makes me think about keeping components pure by using the recompose library to map props and not directly import them in to make it easier to mock them out.

Jest allows you to setup mocks for certain paths. I'm not that experienced with Storybook, so maybe there is a way to mock out modules such as Meteor in the way Jest mocks it out.

Things that need to be worked out

I haven't figured out how to import css in yet such as bootstrap, but that isn't Vulcan specific so should be worked out easily.

Code

// ClientItem.js

import React from 'react'

const ClientsItem = ({ client, currentUser, check, columns, Components }) => (
  <tr>
    {columns.map((column, i) => (
      <td key={column.field}>
        <column.component document={client} />
      </td>
    ))}

    <td>
      {check(currentUser, client) ? (
        <Components.ModalTrigger label="Edit Client">
          <Components.ClientsEditForm
            currentUser={currentUser}
            documentId={client._id}
          />
        </Components.ModalTrigger>
      ) : null}
    </td>
  </tr>
)

export default ClientsItem
// ClientsItemContainer.js
import React, { PropTypes, Component } from 'react'
import { registerComponent, Components } from 'meteor/vulcan:core'
import { withProps, compose } from 'recompose'
import { Link } from 'react-router'

import Clients from '../../modules/clients/collection.js'
import ClientsItem from './ClientsItem'

const enhance = compose(
  withProps(ownerProps => ({
    check: Clients.options.mutations.edit.check,
    Components
  }))
)

const ClientsItemContainer = enhance(ClientsItem)

registerComponent('ClientsItem', ClientsItemContainer)

export default ClientsItemContainer
// clientItem.stories.js

import React from 'react'

import { storiesOf } from '@storybook/react'
import { action } from '@storybook/addon-actions'
import { linkTo } from '@storybook/addon-links'

import { Button, Welcome } from '@storybook/react/demo'
import ClientsItem from '../components/clients/ClientsItem'
import { Link } from 'react-router'
import moment from 'moment'

const columns = [
  {
    label: 'Name',
    field: 'firstName',
    order: 10,
    component: ({ document: d }) => (
      <Link to={`/client/${d._id}`}>{`${d.firstName} ${d.lastName}`}</Link>
    ),
    sort: true
  },
  {
    label: 'Last Interaction',
    field: 'lastInteraction',
    order: 10,
    component: ({ document: d }) =>
      d.lastInteraction ? (
        <div>
          {moment(d.lastInteraction).format('dddd, MMMM Do YYYY, hh:mm:ss')}
        </div>
      ) : (
        'N/A'
      ),
    sort: true
  }
]

const ComponentsMocks = {
  ModalTrigger: () => <div>Edit Client</div>,
  EditForm: () => <div />
}

storiesOf('Client Single', module).add('with Text', () => (
  <ClientsItem
    check={() => true}
    Components={ComponentsMocks}
    columns={columns}
    client={{
      _id: 123,
      firstName: 'Jeff',
      lastName: 'Lau',
      lastInteraction: new Date()
    }}
  />
))
Discordius commented 6 years ago

The part where you have to mock out the child component that are referenced via Component.* seems worst to me. For my codebase, this would make refactoring components a giant pain, and generally require massive amount of mocking (and make it hard to actually test the UI, because you can't really see a single full Post or a Comment or a Toolbar, because they all have subcomponents).

jefflau commented 6 years ago

@Discordius Hey thanks for the feedback. I ran into this issue and what I did was to import the subcomponents (the non-container versions of them). It was a reasonable amount of mocking, but not much more than i'd have to do if I was testing it with a normal test framework anyway.

In my normal app, my <ClientItem /> is wrapped like above, but in the mock test I'm not composing it and just mocking the data. This is the best solution I could come up with yesterday. There might be a better a way.

The only things I couldn't really mock were the Vulcan specific components, but if I just want to test my components, it's not so bad. So stuff like the Modal Trigger I haven't figured out a way to get that out yet, although I'm not too worried about that - I mainly wanted it for UI development of my own components.

import React from 'react'

import { storiesOf } from '@storybook/react'
import { action } from '@storybook/addon-actions'
import { linkTo } from '@storybook/addon-links'

import { Button, Welcome } from '@storybook/react/demo'
import ClientsList from '../components/clients/ClientsList'
import ClientsItem from '../components/clients/ClientsItem'
import { Link } from 'react-router'
import moment from 'moment'

const columns = [
  {
    label: 'Name',
    field: 'firstName',
    order: 10,
    component: ({ document: d }) => (
      <Link to={`/client/${d._id}`}>{`${d.firstName} ${d.lastName}`}</Link>
    ),
    sort: true
  },
  {
    label: 'Last Interaction',
    field: 'lastInteraction',
    order: 10,
    component: ({ document: d }) =>
      d.lastInteraction ? (
        <div>
          {moment(d.lastInteraction).format('dddd, MMMM Do YYYY, hh:mm:ss')}
        </div>
      ) : (
        'N/A'
      ),
    sort: true
  }
]

const mockData = [
  {
    //...
  }
]

const ComponentsMocks = {
  ModalTrigger: () => <div>Edit Client</div>,
  EditForm: () => <div />,
  ClientsItem: ({ client }) => (
    <ClientsItem
      check={() => true}
      Components={ComponentsMocks}
      columns={columns}
      client={client}
    />
  ),
  ClientsNewForm: () => <div />
}

storiesOf('Clients List', module).add('with Text', () => (
  <ClientsList
    results={mockData}
    currentUser={{}}
    loading={false}
    loadMore={() => true}
    count={mockData.length}
    totalCount={mockData.length}
    terms={{}}
    setTerms={() => true}
    Components={ComponentsMocks}
  />
))
eric-burel commented 6 years ago

For style import I use a main decorator that imports all needed libs.

import jquery from 'jquery';
global.$ = jquery
global.jQuery = jquery
require('bootstrap/dist/js/bootstrap');
import 'bootstrap/dist/css/bootstrap.min.css';
import React from 'react'
import { Grid } from 'react-bootstrap'
import styled, { ThemeProvider } from 'styled-components';

export const MainDecorator = (story) => (
  <Grid>
    {story()}
  </Grid>
)
export default MainDecorator

Then in your storybook config.js simply write addDecorator(MainDecorator).

Concerning Meteor + Storybook, another direction would be to replace Node by Meteor as the runtime executable. I don't know if it is possible, but it would be far easier than trying to mock the whole world.

Right now I avoid Meteor stuffs in my React components too but that can be annoying. This is especially true in Vulcan, that exploits cleverly the Meteor features even for purely frontend stuffs, like registerComponent.

Also, Storybook should not be only a unit development interface, it should also enable people to test fully integrated components, including Meteor/React components. In other projects I use it with full fledged containers, and even allow API calls. That makes me gain an infinite amount of time and that is the desirable goal. I think forking/modifying Storybook so that we get a real Meteor support is still the best direction here.

Hypnosphi commented 6 years ago

Storybook does not understand how to parse the meteor npm imports.

Can you please elaborate this part?

SachaG commented 6 years ago

@Hypnosphi a sample Meteor import might be:

import { Components } from 'meteor/vulcan:core'

Hypnosphi commented 6 years ago

So what's the error when you try to do that?

jefflau commented 6 years ago

@Hypnosphi

Meteor packages are kept instead the .meteor folder and to be compliant with certain tools allows you to use 'meteor/[package name]'. Storybook uses webpack so it doesn't have any way of resolving those paths to the actual location of the Meteor package. Therefore for that to work either Meteor packages and the core Meteor packages would have to be on npm or the storybook webpack would have to be able to resolve meteor packages somehow

Hypnosphi commented 6 years ago

does this work?

// .storybook/webpack.config.js
const path = require('path')

module.exports =  {
  resolve: {
    alias: {
      meteor: path.resolve('../.meteor')
    }
  }
}
SachaG commented 6 years ago

No, because Meteor packages are just not NPM packages. They have a package.js instead of a package.json, they rely on Meteor globals, etc.

stale[bot] commented 6 years ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

eric-burel commented 5 years ago

I think we can close this :) Storybook install is documented here: https://github.com/VulcanJS/vulcan-docs/blob/master/source/storybook.md