found icon indicating copy to clipboard operation
found copied to clipboard

Support react-storybook?

Open leesiongchan opened this issue 8 years ago • 29 comments

leesiongchan avatar Dec 05 '17 16:12 leesiongchan

not sure what this issue is about...react storybook should work with any react component.

jquense avatar Dec 05 '17 17:12 jquense

the Link component doesn't work in react storybook.

leesiongchan avatar Dec 05 '17 17:12 leesiongchan

unless you have some information that would help someone solve a bug here there isn't much we can do. I don't think anyone has any interest in making a storybook plugin, and without any details it's hard to think there might be a bug in the library

jquense avatar Dec 05 '17 17:12 jquense

Is the idea that you want to be able to render a (non-functional) link outside of a full fledged router?

taion avatar Dec 05 '17 17:12 taion

Yea, sorry, just trying to ask if there any quick solutions before jumping into creating another layer for storybook like storybook-router

leesiongchan avatar Dec 05 '17 17:12 leesiongchan

Ideally we’d just want that here. It’s sort of the same thing as https://github.com/4Catalyzer/found/issues/136

taion avatar Dec 05 '17 17:12 taion

BTW if you actually want navigation, you can use MemoryProtocol in Farce v0.2.4.

taion avatar Dec 22 '17 19:12 taion

I'm going to close this out in favor of #136 for the "stub router object" case, as the solution ought to be the same – and in the event you want some sort of mock integration, MemoryProtocol or ServerProtocol should both be good.

taion avatar Dec 22 '17 19:12 taion

I'm still not 100% clear on how to solve the following 2 challenges when it comes to use within storybook:

  1. found/lib/Link is not available
  2. Rendering of an HOC using withRouter

Surely this is simple enough to mock when inside a Jest test, as discussed in #136, but when implementing a storybook a decorator would be required.

santino avatar Apr 27 '18 16:04 santino

The idea is that it's the same as in #136. Either use some sort of mock/stub, or render everything under a server router or memory router.

taion avatar Apr 28 '18 01:04 taion

Thanks @taion. For the those devs like me looking for a snippet to make this work here is what a simple storybook implementation would look like

import React from 'react'
import { storiesOf } from '@storybook/react'
import MyComponent from ‘.’

import createFarceRouter from 'found/lib/createFarceRouter'
import createRender from 'found/lib/createRender'
import MemoryProtocol from 'farce/lib/MemoryProtocol'
import resolver from 'found/lib/resolver'

const StoryRouter = createFarceRouter({
  historyProtocol: new MemoryProtocol('/'),
  routeConfig: [{ path: '/', Component: MyComponent }],
  render: createRender({})
})

storiesOf('Header', module).add('default', () => <StoryRouter resolver={resolver} />)

This doesn't give much customisation in terms of locations, routing and history. For this reason I've been working on a simple Storybook decorator to allow matching and navigation. I will be publishing it soon, so please don't close this ticket for now so that I can post it here for the reference of anyone looking into this in the future.

santino avatar May 11 '18 18:05 santino

I've just published storybook-found-router a decorator for React applications using Found routing. It can simply be installed using npm install storybook-found-router -D

@taion it would be great if you could take a look at it since obviously you have the best knowledge on Found and can advice if you see any issue with the implementation of the found instances I'm using.

If you find this helpful it might be useful to mention about this in the your readme file (possibly in the extensions paragraph?)

santino avatar May 12 '18 14:05 santino

Apologies – I've been quite busy lately. I'll try to look at this on Monday.

taion avatar May 12 '18 15:05 taion

Sorry again, I keep forgetting about this. I'm going to re-open this to keep track of it.

taion avatar May 23 '18 19:05 taion

@santino I've just installed your storybook-found-router and can confirm it works out-of-the-box.

I'm migrating an app from React Router v3 to Found Relay and my stories of components rendering Buttons based on Link got broken because of Connect(BaseLink) requiring a store.

Luckily I found your lib, installed it and so far it:

  • [x] works in stories
  • [x] works in Storyshots

Thx a lot

hisapy avatar May 23 '18 22:05 hisapy

Just for the record, one drawback of using found-relay I just discovered is that I can't use relay-mock-ntework-layer directly because my QueryRenderer is now in the router.

I need to figure out how to address this.

hisapy avatar May 23 '18 22:05 hisapy

Wait, why can't you? You still set up your own network layer with Found Relay.

taion avatar May 23 '18 22:05 taion

See e.g. https://github.com/taion/relay-todomvc/pull/28.

taion avatar May 23 '18 22:05 taion

Wait, why can't you? You still set up your own network layer with Found Relay.

I mean I can't use it directly like passing the environment directly to the component I'm writing the story for. I need to setup a router in the story.

hisapy avatar May 23 '18 23:05 hisapy

Hello again.

After 2 hours 34 minutes and 34 seconds I came up with a solution for my found-relay Storybook setup.

// file: PersonForm.story.js
/* global module */
import React from "react";
import { Resolver } from "found-relay";
import createFarceRouter from "found/lib/createFarceRouter";
import createRender from "found/lib/createRender";
import RedirectException from "found/lib/RedirectException";

/* eslint-disable import/no-extraneous-dependencies */
import { storiesOf } from "@storybook/react";
import MemoryProtocol from "farce/lib/MemoryProtocol";
/* eslint-enable */

import routeConfig from "app/routes";
import getEnvironment from "../../test/support/relay-environment-mock";
// import { person /*, personMutationErrors */ } from "../../test/support/data";
// import { person } from "../../test/support/data";

const environment = getEnvironment({});

const StoryRouter = createFarceRouter({
  routeConfig,
  historyProtocol: new MemoryProtocol("/"),
  render: createRender({})
});

const relayResolver = new Resolver(environment);

// Redirects to path when rendering the Router
const storyResolver = path => ({
  resolveElements(match) {
    const { location } = match;

    if (location.pathname !== path) {
      throw new RedirectException(path);
    }

    return relayResolver.resolveElements(match);
  }
});

storiesOf("people/PersonForm", module)
  .add("creating new Person", () => (
    <StoryRouter resolver={storyResolver("/people/new")} />
  ));

The most difficult part was to find out how to render an already created StoryRouter on the path of the corresponding Component. I tried some story components withRouter and other stuff but none of them worked so I decided to investigate the resolver prop. Once I got there I was trying to yield router.replace(path) with errors but after further investigating on generators I discovered that you can stop the iteration by throwing an exception and that's how I can render the Router in an specific route.

After this experience, now that I have to test my Relay based components through the router, with this setup I can say I expanded my test coverage. Now I'm also testing my routeConfig with my Storyshots tests. Before this experience, I didn't have any test for my UI routing.

The only caveat is that you have to install babel-plugin-dynamic-import-node to run your Storyshots tests if you're using import(...) - I'm using Jest by the way.

hisapy avatar May 24 '18 16:05 hisapy

I'm sorry you had to spend so much time on this.

Why do you need to trigger the redirect rather than just creating the MemoryProtocol with the path you want?

taion avatar May 24 '18 16:05 taion

Yeah, but so far I think it wasn't a waste of time 😅

I trigger the redirect to avoid creating a new Router each time I need to render a story, also avoiding creating a higher-order component inside a render method.

Otherwise, if I'm not wrong, I would have to do something like the following for each story:

const StoryRouter = createFarceRouter({
  routeConfig,
  historyProtocol: new MemoryProtocol(path),
  render: createRender({})
});

By the way, thx for the Found router @taion . It works great so far. The only thing a little bit awkward is when googling something about it ... you know Found is not a typical keyword for searches 😆

hisapy avatar May 24 '18 18:05 hisapy

Again just for the record, using the same resolver technique I easily and finally added a StoryShot for error handling at the Router.

// file: stories/routes/HttpError.story.jsx
/* global module */
import React from "react";
import createFarceRouter from "found/lib/createFarceRouter";
import createRender from "found/lib/createRender";
import HttpError from "found/lib/HttpError";

/* eslint-disable import/no-extraneous-dependencies */
import { storiesOf } from "@storybook/react";
import MemoryProtocol from "farce/lib/MemoryProtocol";
/* eslint-enable */

import routeConfig, { renderError } from "app/routes";

const StoryRouter = createFarceRouter({
  routeConfig,
  historyProtocol: new MemoryProtocol("/"),
  render: createRender({ renderError })
});

storiesOf("routes/HttpError", module).add("401", () => (
  <StoryRouter
    resolver={{
      resolveElements() {
        throw new HttpError(401);
      }
    }}
  />
));

This is actually getting better 😃

hisapy avatar May 25 '18 00:05 hisapy

I've just stumbled with an essential detail. In order to achieve reproducible snapshots, using the MemoryProtocol, we need to mock Math.random before creating the router. For example:

// NOTICE:
// We need to mock Math because MemoryProtocol uses a random _keyPrefix for the routes
const realMath = global.Math;
const mockMath = Object.create(global.Math);
mockMath.random = () => 0.5;
global.Math = mockMath;

const StoryRouter = createFarceRouter({
  routeConfig,
  historyProtocol: new MemoryProtocol("/"),
  render: createRender({})
});

// Put back the realMath (don't know if this is necessary though)
global.Math = realMath;

hisapy avatar May 25 '18 15:05 hisapy

@hisapy Do you want to PR to Farce and make keyPrefix specifiable as an option?

https://github.com/4Catalyzer/farce/blob/d3e6e0cdd6a6ee43b7acc8d8b67ed00cefc76ce1/src/MemoryProtocol.js#L9-L27

taion avatar May 25 '18 16:05 taion

Hello again guys,

I didn't have for PR to make keyPrefix as an specifiable option but anyway, I want to share my conclusions after more than 1 month using the StoryRouter (see previous comments) approach, but first, let's recap that this approach was taken to address the creation of stories of components rendering Link from found/lib/Link.

The following statements assume the use jest of enzyme's mount to render the snapshots generated by StoryShots.

1. Using the StoryRouter is only useful for stories and StoryShots where you want to test that the router match a given path:

This is because the only Component rendered in the snapshot is the Route config. For example:

<div>
  <FarceRouter
    resolver={
      Object {
        "resolveElements": [Function],
      }
    }
  >
    <Provider
      store={
        Object {
          "dispatch": [Function],
          "farce": Object {
            "matcher": Matcher {
              "routeConfig": Array [
                // routeConfig rendered here
           // other stuff of store rendered here
      }>
        <Connect(BaseRouter) ... >
 //  lots of router stuff here    
</div>

From Storybook, everything renders properly, but from testing point of view a route just render as

Route {
  "Component": [Function],
  "path": "new",
  "prepareVariables": [Function],
  "query": [Function] // this is here for found-relay
}

It doesn't go deeper than this. I discovered because one time, the StoryShots tests was all green even though a couple of stories were broken in the Storybook.

The only interesting part a snapshot generated rendering a StoryRouter is

          resolvedMatch={
            Object {
              "location": Object {
                "action": "POP",
                "delta": 0,
                "hash": "",
                "index": 0,
                "key": "i:1",
                "pathname": "/admin/users/VXNlcjo5/edit",
                "query": Object {},
                "search": "",
                "state": undefined,
              },
              ....
         }

It is basically an assertion that the path used for the MemoryProtocol actually exists in the routeConfig, in this case /admin/users/:id/edit.

2. Mock found/lib/Link to write stories for components rendering Link instead of using the StoryRouter approach

In order to render your component tree instead of just Function in the generated snapshots we need to render the component without the router and to avoid Link errors because the router is not in context and because there is no Provider wrapping the Connected component we need to mock the Link implementation.

To mock Link at the Storybook level, add the following alias in the resolve section of your Storybook webpack.config.js

resolve: {
    alias: {
      // the expected mock module is in the same dir but can another if needed
      "found/lib/Link": require.resolve("./found-link-mock.jsx"),
    }
}

and add the corresponding mock implementation

// file: found-link-mock.jsx

import React from "react";
import { action } from "@storybook/addon-actions";

// Set the custom link component.
export default class Link extends React.Component {
  handleClick(e) {
    e.preventDefault();
    const { to } = this.props; // eslint-disable-line react/prop-types
    action("Link")(to);
  }

  render() {
    // eslint-disable-next-line react/prop-types
    const { children, style, className } = this.props;

    return (
      <a
        className={className}
        style={style}
        href="#"
        onClick={e => this.handleClick(e)}
      >
        {children}
      </a>
    );
  }
}

Now you should be able to see stories of components rendering Link that log event using the ActionLogger addon.

And for StoryShots you can use the same mock, in your StoryShots.test.js file. For example

// file: test/StoryShots.test.js

import initStoryshots from "@storybook/addon-storyshots";
import { mount } from "enzyme";
import toJson from "enzyme-to-json";

// Use the same mock used for Storybook
jest.mock('found/lib/Link', () => require('../.storybook/found-link-mock'))

initStoryshots({/* options */})

you can use a similar approach to mock withRouter if needed

3. Found Relay

If you have Relay components, then you'd like to export the graphql queries from the module where your routeConfig is defined and render the component using the QueryRenderer with an environment from relay-mock-network-layer and with manually set variables prop, for example if you have a path post/:id.

Conclusion

In general, you should avoid the StoryRouter approach for StoryShots. As soon as I have time I'd like to write a post about this subject, specially the Relay environment mock part because it let's you write Stories with the same source of truth as the original app.

hisapy avatar Jul 06 '18 14:07 hisapy

For (2) above, I think it should be possible to define a dummy context provider that just gives <Link> the bare minimum needed to render.

taion avatar Jul 06 '18 14:07 taion

Yeah I was thinking something like that but haven't found how to define the provider based on the environment (dev, storybook, test)

For number (3), I noticed that in order to take StoryShots of our Relay components and not just the <QueryRenderer />, we need to wait until the render prop is done getting the data for the component.

In this sense, I submitted this pull request to add pass the Jest done callback to the testMethod of the StoryShots addon. Once merged, the same technique might be used to write StoryShots directly from the application routeConfig using the StoryRouter approach.

hisapy avatar Jul 06 '18 14:07 hisapy

Well, in most cases, if you're not actually exercising the navigation logic, you really just need to provide the URL, plus createHref/createLocation methods for links.

taion avatar Jul 09 '18 17:07 taion