anthonyshort / deku

Render interfaces using pure functions and virtual DOM
https://github.com/anthonyshort/deku/tree/master/docs
3.41k stars 130 forks source link

Is there any example of router in deku #149

Closed ghost closed 9 years ago

ghost commented 9 years ago

I'm looking for an example of router in deku. It would be nice if anyone can share it :dancer:

I'm excited with Deku :+1:

anthonyshort commented 9 years ago

There isn't a full router like React Router for Deku. Someone could build one if they wanted to. We treat routes as a data source though. For example, given this url:

/anthonyshort/projects/244242

This route has a user and a project associated with it, and maybe a name:

/:user/projects/:project

This route could be represented by an object:

{
  name: 'View Project',
  path: '/anthonyshort/projects/244242',
  resources: {
    user: {
      username: 'anthonyshort',
      email: anthony@foo.com,
      name: 'Anthony Short'
      // maybe some more user model stuff here
    },
    project: {
      id: 244242,
      name: 'My Project'
    }
  }
}

We can set this on the app whenever the route changes:

router.on('/:user/projects/:project', function(params){
 // use the params to fetch the resources then...
 app.set('currentRoute', { ..route object here })
})

And then access the current route in our components using propTypes:

var propTypes = {
  'route': { source: 'currentRoute' }
}

function render({ props }) {
  let {project,user} = props.route.resources
  if (props.route.name === 'View Project') <ProjectView project={project} />
}

If you treat the router like an other external data source it becomes really easy to reason about everything. The route is external state that modifies the way your app renders.

joshrtay commented 9 years ago

what client side router are you guys using?

anthonyshort commented 9 years ago

We use https://github.com/ianstormtaylor/router, but you can use pretty much anything.

ghost commented 9 years ago

thanks @anthonyshort for your information. And indeed, deku makes things so much simple that we can build our stuffs to work with it :)

anthonyshort commented 9 years ago

I might write a guide that explains how routing can work. This will probably come up a lot.

MarshallChen commented 9 years ago

please, add an example for router, thanks @anthonyshort

cpursley commented 9 years ago

:+1:

An interesting router that I came across recently is the arch framework router. A router along these lines for deku would rock.

Kureev commented 9 years ago

I'm curious about the way you "associate" routes with data. Could you be more explicit about this point?

UPD: Oh, nvm, just took a look on router and it became really clear :+1:

MarshallChen commented 9 years ago

ianstormtaylor/router this router cannot installed from npm, so I tried visionmedia/page.js and works well with your suggestions, thank you @anthonyshort

joshrtay commented 9 years ago

@anthonyshort - It would be cool if you included examples of how to incorporate async data and how to structure nested views in the guide.

For async data, it seems to me that it would be useful to be able to set promises on the tree, but I assume you guys have come up with another way of dealing with async data.

voronianski commented 9 years ago

@anthonyshort :+1:

voronianski commented 9 years ago

@anthonyshort how do you handle different layout based pages? For example I'm creating app.tree(<App />) once but /login or /notfound pages have completely different layouts. Should I somehow mount new tree?

lancejpollard commented 9 years ago

@voronianski we're just in the middle of figuring this out ourselves too :) you probably know more than we do at this point.

We're starting to move toward a single-page app setup, but yeah some of the pages are totally different than others. For that starting to think about this.

First, you should only need 1 deku instance. So been looking at doing something like this:

/**
 * Module dependencies.
 */

import {appLoaded} from '../analytics';
import {dom} from 'dekujs/deku';

/**
 * Define properties.
 */

export const propTypes = {
  mouseOutHandler: { type: 'function', source: 'mouseOutHandler' },
  keydownHandler: { type: 'function', source: 'keydownHandler' },
  clickHandler: { type: 'function', source: 'clickHandler' },
  dialog: { type: 'object', source: 'currentDialog' },
  popup: { type: 'object', source: 'currentPopup' },
  page: { type: 'object', source: 'currentPage' }
}

/**
 * After mount, fire app loaded event.
 */

export function afterMount() {
  appLoaded()
}

/**
 * Render the "App".
 */

export function render({props}){
  let {mouseOutHandler, keydownHandler, clickHandler} = props
  let {dialog, popup} = props
  let {page} = props
  let handlers = {
    onMouseOut: mouseOutHandler,
    onKeyDown: keydownHandler,
    onClick: clickHandler
  }

  return (
    <div class='App' {...handlers}>
      <div class='App-body'>{page}</div>
      <div class='App-overlays'>
        {dialog}
        {popup}
      </div>
    </div>
  )
}

That's like the main "layout" or I guess "app". It has a way to handle all the local-to-global components (dialogs, tooltips, popups, etc.), and how to close them, and how to handle keyboard shortcuts and such.

Then you'd use it like this

/**
 * Module dependencies.
 */

import App from './app'

/**
 * Initialize the app.
 */

deku(<App/>)
  .use(components)
  .use(actions)
  .use(routes)
  .use(sources({
    currentUser: user
  }))
  .use(debugging)
  .use(rendering)

And the routes would do something like this:


/**
 * Module dependencies.
 */

import page from 'visionmedia/page.js'

/**
 * Expose `routes`.
 */

export default routes

/**
 * Define routes.
 */

function routes(app) {
  setup()

  function setup() {
    page('/', home)
    page('/login', login)
    page('/notfound', notfound)
  }

  function login() {
    app.set('currentPage', <Login/>)
  }

  function notfound() {
    app.set('currentPage', <NotFound/>)
  }
}

And here's some example "actions" used to change routes.

/**
 * Module dependencies.
 */

import page from 'visionmedia/page.js'
import * as url from './urls'

/**
 * Expose `actions`.
 */

export default actions

/**
 * Define actions for app.
 *
 * These would be used in components like `login: { source: 'visitLogin' }`,
 * and called on click of a link, for example.
 */

function actions(app) {
  setup()

  /**
   * Setup routes and route actions.
   */

  function setup() {
    app.action = app.set // for intuitive dsl, defining actions.

    // actions
    app.action('visitNotfound', visitNotfound)
    app.action('visitLogin', visitLogin)
  }

  function visitNotfound() {
    page(url.notfound())
  }

  function visitLogin() {
    page(url.login())
  }
}

Going to be spending more time on this stuff this week, but maybe these initial thoughts spark some ideas.

How does something like that feel so far?

Kureev commented 9 years ago

@lancejpollard I think you boilerplate looks good, but I guess @voronianski asked a bit different question.

@voronianski good question, I guess it should be done as a top-level abstraction over deku.

lancejpollard commented 9 years ago

@Kureev oh that demoes a layout system too (assuming no browser refreshes, and that everything is a single-page app).

Basically every totally different page would just be that page variable in <div class='App-body'>{page}</div>. Everything else in that component is non-visual, so each page would have full control over how it looks, without requiring separate deku instances, or building an abstraction an top of deku.

lancejpollard commented 9 years ago

With es6 syntax maybe it could be abstracted further :p

route('/notfound', => <NotFound/>)
route('/login', => <Login/>)

function route(path, render) {
  page(path, function(){
    app.set('currentPage', render())
  })
}

But the main thing here is, while these routes just swap out a single component, some more deeply nested routes might change some other data thing (like currentProject instead of currentPage), so this probably wouldn't be a general solution.

voronianski commented 9 years ago

@lancejpollard thanks for your code examples, they look good :+1:

What about data inside routes? I assume from previous examples that you're getting it somehow similar to this way:

import page from 'page';

import Dashboard from './Dashboard';

export default function (app) {
    page('/login', login);
    page('/editors', checkAuth, editors);
    page('/editors/:id', checkAuth, editorById);
    page('/products', checkAuth, products);
    page('/products/:id', checkAuth, productById);
    page('*', notfound);
    page();

    function login () {
        app.set('currentPage', <Login />);
    }

    function editors () {
        app.set('currentPage', <Dashboard />);
        api.getEditors(data => app.set('currentPageData', data));
    }

    function products () {
        app.set('currentPage', <Dashboard />);
        api.getProducts(data => app.set('currentPageData', data));
    }

    function notfound () {
        app.set('currentPage', <NotFound />);
    }

    //...
}

However I'm also thinking that it's possible to treat <Dashboard /> as a container element that will use necessary flux actions (or just plain API calls) in afterMount to re-render with fetched data, very common pattern in React. What do you think?

Anyway I think the main objective should be to have as much re-renders as possible, for example for shared layouts between different routes.

lancejpollard commented 9 years ago

@voronianski Awesome! For the first part of your question about adding data in routes, there's two main cases at least:

  1. The route sets data in the view it's rendering.
  2. The route only sets data.

For the first one, we use that when we're on a specific page in our dashboard, and open a modal window with the create form for some resource. That also changes the URL. So it might be like this:

export const propTypes = {
  visitEdit: { source: 'visitEditUser' },
  user: { type: 'object' }
}

export function render({props}) {
  let {visitEdit, user}

  return <div onClick={editUser}>Edit</div>

  function editUser() {
    visitEdit(user)
  }
}
app.set('visitEditUser', function(user){
  page(`/users/${user.id}/edit`)
})
page('/users/:id/edit', editUser)

function editUser(ctx) {
  let userId = ctx.params.id
  // try finding from memory so UI loads immediately if it can
  let user = api.getUser(userId)

  app.set('route', {
    resource: 'user',
    action: 'edit'
    user: user
  })
  // maybe try experimenting with app.set('editedUser', user), haven't tried yet
}

We then have bindings to our data store to listen for changes, similar to flux but more along the lines of the node-world conventions.

// very generic data binding, basically just listen for 'change'
// on each different collection, and then recompute anything we need,
// normally just a list of stuff, maybe some counts
api.users.on('change', function(){
  app.set('users', querySomeUsers()) // fetch fresh users after some change
})

In the above snippets, you'll see that app.set('route', stuff). We are using that in a few places. We then inside some page like your <Dashboard/>, you have a source route: { source: 'route' }. (Maybe could even get more specific, and have dashboardRoute and such, haven't experimented with that too much yet). Then you can use that route "data" in some component to change what it renders. Like

let label = 'edit' == action ? 'Update' : 'Create'

Does that answer the first part of your question? For the second part, not quite sure what you're asking:

However I'm also thinking that it's possible to treat as a container element that will use necessary flux actions (or just plain API calls) in afterMount to re-render with fetched data, very common pattern in React. What do you think?

Anyway I think the main objective should be to have as much re-renders as possible, for example for shared layouts between different routes.

Would you mind clarifying and I'll see if I can be of more help :)

lancejpollard commented 9 years ago

The other way to do "routes with data" is to pass the data into your component. Just:

function products () {
    api.getProducts(data => app.set('currentPage', <Dashboard data={data} />));
}

But, that would be a blank screen until the data loaded, so for that we've been putting the async call in afterMount like it sounds like you're saying. There should be a better way tho, just haven't found it yet.

voronianski commented 9 years ago

@lancejpollard thanks for the detailed answer.

In my second part question I was talking about smth similar to such example:

// container component..
const Dashboard = {
  initialState() {
    return { items: [] };
  },

  afterMount(component, updateState) {
     api.getItems(items => updateState({ items });
  },

  render(component) {
    const { state } = component;
    if (!state.items.length) {
       return <div>Loading...</div>;
    } 
    return (
       <div>
           <List items={state.items} />
       </div>
    );
  }
};

// ...just renders on specific url
page('/editors', checkAuth, editors);
function editors () {
   app.set('currentPage', <Dashboard />);
}
lancejpollard commented 9 years ago

@voronianski Yep! We're doing basically the same thing in a few places :)

megamaddu commented 9 years ago

I'm basically doing what @anthonyshort initially suggested in a React app using a minimalistic router I put together ('lucid' as in simple): https://github.com/spicydonuts/lucid-router

The router itself is plain JS and just fires a callback when the location changes. It uses the history API and falls back on regular redirects when the route can't be matched. Working it into the app was a one-liner: router.register(location => appState.cursor('location').withMutations(() => location));

Anyway, just started looking at Deku and looking forward to giving it a try!

troch commented 9 years ago

I am working on a router project, it is not tied to any library or framework but I have designed it with applications based on component trees in mind: https://github.com/router5/router5 (project is early stage, website needs to be created).

I have plans for integration with React and Deku. I am currently working on integration with React (Mixins and Link component).

I have played with Deku and router5 together and it showed promise. I don't have a full picture on how deku internals work, especially environment data (sources) and plugins. If anyone is interested and/or has the vision for a router5-deku integration, you are more than welcome to help!

megamaddu commented 9 years ago

@troch Looks like a cool project. I might steal your nested route syntax :D

troch commented 9 years ago

@spicydonuts Be my guest :). I have exploded it in specialised modules on purpose: path-parser for building / matching paths, and route-node for building a tree of named routes.

megamaddu commented 9 years ago

Cool, I'll have a look at those too. I basically wrapped url-pattern in an html5 history-aware wrapper, with fallbacks. He's doing a pretty good job with that project, with a refactor coming up that uses an AST to make the matching code simpler while also letting you go backwards (generate urls from a route name and params). I might bring up nested patterns there because it'd be a lot easier to do at the AST level. The only way I could do it is by flattening the routes, which would just make it an easier syntax for multiple routes that share a root path.

troch commented 9 years ago

I looked at url-pattern and route-parser, and another source of inspiration was routington.

I needed:

url-pattern and route-parser don't have those three, that's why I came up with my own path parser / tokeniser. Routington is not bad but parses paths by "/" rather than existing routes.

troch commented 9 years ago

Thinks are progressing. A website is live: router5.github.io. I can now fully focus on inegration with React and Deku.

I wonder what would be the best approach with Deku. My initial thoughts are: if I use source through app.set('currentRoute', {...}), it won't do any good for nested routes. I would then need to nest trees. Other approach is to do sideways updates using router5.addNodeListener(node, fn) which is triggered if re-rendering needs to happen on a specific node. Does anyone have an opinion?

troch commented 9 years ago

For info router5 is in rc and router5-deku in alpha.

anthonyshort commented 9 years ago

@troch This is great. I'd love to help out more with this. You could probably use sources for now, but we're adding support for context and the ability to mutate the component tree in the next version. I'll need to look more at your implementation for it / the docs to know how we could help expose some better APIs for things like this.

troch commented 9 years ago

@anthonyshort Thanks. I have used source for links, so they can update their active state.

For route changes and updating views / components, source is only useful if you have 1 level deep routes (and it is perfectly possible with the decorator I provide: just don't pass a listener to it -or don't use it at all- and set currentRoute as a prop from source).

But if you have more than one level deep routes, then you need to only target the deepest common node between your previous and current route, and you don't want source to cause unnecessary renders.

troch commented 8 years ago

Just to let you know, I have released router5@1.0.0. As part of the release, deku-router5 has been completely reworked (and renamed, previously router5-deku). It makes available a plugin, a HOC (for route nodes) and a link/button component.

Example Running example

anthonyshort commented 8 years ago

@troch Nice! Checking it out now.