Routes

In this tutorial, we will go through client side routing with Live Elements. We will do this by creating an app to manage a movie collection.

The tutorial uses the setup made in the quick start article. Basically, we create an empty folder, and then use lvweb inside the folder to generate a starting project:

mkdir routes
cd routes
npm i live-elements-web-cli
npx lvweb init
npm i
lvweb serve

To check, we can go to localhost:8080, and we should see the following text:

Welcome to Live Elements

Setup

Before editing the routes on our client side, there are a few things we need to setup.

We need to have a style for our collection. In your project folder, replace the contents in styles/style.css with the style file located here.

We will also need a small database, so in the project root folder, create a db folder, and inside the folder create a file called db.json, and add some content:

[
    {
        "id": 1,
        "name": "Movie Title #1",
        "description": "Movie Description #1"
    },
    {
        "id": 2.
        "name": "Movie Title #2",
        "description": "Movie Description #2"
    }
]

A shortcut would be useful to read/write the file quickly. Next to db.json, create a file called db.mjs:

import fs from 'fs'
import path from 'path'
import url from 'url'

const currentDir = path.dirname(url.fileURLToPath(import.meta.url)) 

export function readDb(){
    const filePath = path.join(currentDir, 'db.json')
    return JSON.parse(fs.readFileSync(filePath))
}

export function writeDb(content){
    const filePath = path.join(currentDir, 'db.json')
    fs.writeFileSync(filePath, JSON.stringify(content))
}

Your file structure should now look like this:

 |-- live.package.json
 |-- package.json
 |-- app
 |   |-- Home.lv
 |-- db
 |   |-- db.json
 |   |-- db.mjs
 |-- bundle
 |   |-- bundle.lv
 |-- styles
 |   |-- style.css

Next, we will need api endpoints to view/update our database. We can configure those in bundle/bundle.lv. We will need:

  • GET /api/list to list all entries
  • POST /api/add to add a new entry
  • POST /api/update/:id to update an entry
  • POST /api/remove/:id to remove an entry
import live-elements-web-server.bundle
import live-elements-web-server.router
import live-elements-web-server.style
import .app

import { readDb, writeDb } from '../db/db.mjs'

instance bundle Bundle{
    Stylesheet{ src: './styles/style.css' output: 'style.css' }
    ViewRoute{ url: '/' c: Home }
    
    GetRoute{ url: '/api/list' f: async(req, res) => {
        res.json(readDb())
    }}
    
    PostRoute{ url: '/api/add' f: async(req, res) => {
        const content = readDb()
        const newItem = {
            id: content.length + 1,
            title: req.body.title,
            description: req.body.description
        }
        content.push(newItem)
        writeDb(content)
        res.json(newItem)
    }}

    PostRoute{ url: '/api/update/:id' f: async(req, res) => {
        const content = readDb()
        const item = content.find(entry => entry.id === parseInt(req.params.id))
        item.title = req.body.title
        item.description = req.body.description
        writeDb(content)
        res.json(item)
    }}

    PostRoute{ url: '/api/remove' f: async(req, res) => {
        const content = readDb()
        const updatedContent = content.filter(entry => entry.id !== parseInt(req.body.id))
        writeDb(updatedContent)
        res.json({id: req.params.id})
    }}

}

The routes are basically express route implementations. In each api route we use readDb to read the database content, then modify the content according to each route, and then writeDb to write the modified content back to the database.

We can run lvweb serve and check localhost:8080/api/list to see if we get the list of entries.

Now that we have our api up and running, it's time to create the client.

Client side routes

On the client side, we're going to need the following routes:

  • /view/:id: to view a single movie entry. Additionaly, this view will have a sidebar with a list of all the entries.
  • /: index page, to view the last movie entry
  • /edit/:id: edit a movie entry
  • /add: add a new entry

We'll start with the /view/:id route. We'll need a component for this route, that will have a sidebar with all the movies, so the user can select an entry to view, and a main section where we will show the movie currently being viewed, together with an edit and a remove button. Let's create MoviePage component in app/pages/MoviePage.lv.

MoviePage

Inside app/pages/MoviePage.lv file, we will define the following:

  • A state to store the movie in:
    component MovieState < State{
        number idx: 0
        string title: ''
        string description: ''
    }
  • A state for the page:
    component MoviePageState < State{
        Array<MovieState> movies: []
        MovieState current: null
        boolean isLoading: false
        string error: ''
    }
    The page stores the list of movies, the current selected movie, whether there's anything loading, or whether an error occured anywhere on the page.
  • The actual MoviePage component which will render the view.

The file will look like this:

import live-web.dom
import live-web.model
import live-web.clientrouter

import ky from 'ky'

component MovieState < State{
    number idx: 0
    string title: ''
    string description: ''

    static fn fromJSON(ob:Object){
        return MovieState{
            idx = ob.id
            title = ob.title
            description = ob.description
        }
    }
}

component MoviePageState < State{
    MovieState current: null
    Array<MovieState> movies: []
    boolean isLoading: false
    string error: ''

    fn removeCurrentEntry(){
        //TODO: Implement
    }

    fn completed(){
        this.isLoading = true
        ky.get('/api/list').json()
            .then(data => {
                this.movies = data.map(entry => MovieState.fromJSON(entry))
                const routeInfo = ClientNavigation.currentRoute()
                if ( routeInfo.data && routeInfo.data.id ){
                    this.current = this.movies.find(m => m.idx === parseInt(routeInfo.data.id ))
                } else if ( this.movies.length ){
                    this.current = this.movies[this.movies.length - 1]
                }
                this.isLoading = false
            })
            .catch (error => {
                this.error = error
                this.isLoading = false
            })
    }
}

component MoviePage < Div{
    id: moviePage
    classes: ['container']

    MoviePageState state: MoviePageState{}

    Div{ classes: ['sidebar']
        H2{ 
            T{ text: 'Movies' } 
            A`+|/add`
        }
        Ul{
            children: moviePage.state.movies.map(entry => (Li{
                NavLink.(entry.title){ href: `/view/${entry.idx}` }
            }))
        }
    }
    Div{ classes: ['main']
        H1{ T{ text: moviePage.state.current?.title ? moviePage.state.current.title : ''}}
        P{ T{ text: moviePage.state.current?.description ? moviePage.state.current?.description : ''} }
        Button{ 
            classes: ['edit-btn', moviePage.state.current ? '' : 'hidden'] 
            on click: () => { ClientNavigation.goTo(`/edit/${moviePage.state.current.idx}`) }
            T`Edit` 
        }
        Button{ 
            classes: ['delete-btn', moviePage.state.current ? '' : 'hidden'] 
            on click: () => {
                if ( window.confirm(`Are you sure you want to delete '${moviePage.state.current.title}'?`)){
                    moviePage.state.removeCurrentEntry()
                }
            }
            T`Remove` 
        }
    }

    Div{ classes: ['modal', moviePage.state.isLoading ? 'show' : '']
        Div{ classes: ['spinner'] }
    }

    Div{ classes: ['modal', moviePage.state.error ? 'show' : '']
        Div{ classes: ['error-message'] T{ text: moviePage.state.error } }
        on click: () => moviePage.state.error = ''
    }
    
}

In MoviePageState we've implemented the completed() function. This gets triggered automatically when the component finishes loading:

fn completed(){
    this.isLoading = true
    ky.get('/api/list').json()
        .then(data => {
            this.movies = data.map(entry => MovieState.fromJSON(entry))
            const routeInfo = ClientNavigation.currentRoute()
            if ( routeInfo.data && routeInfo.data.id ){
                this.current = this.movies.find(m => m.idx === parseInt(routeInfo.data.id ))
            } else if ( this.movies.length ){
                this.current = this.movies[this.movies.length - 1]
            }
            this.isLoading = false
        })
        .catch (error => {
            this.error = error
            this.isLoading = false
        })
}

The function sets isLoading property to true. The property will notify the spinner modal in MoviePage to activate:

Div{ classes: ['modal', moviePage.state.isLoading ? 'show' : '']
    Div{ classes: ['spinner'] }
}

Next, the function uses ky library to do a fetch request to /api/list:

ky.get('/api/list').json().then(...)

If the request succeeds, the movies property gets set to the result:

this.movies = data.map(entry => MovieState.fromJSON(entry))

This will trigger the sidebar in MoviePage to automatically render the list of movies:

Div{ classes: ['sidebar']
    H2{ 
        T{ text: 'Movies' } 
        A`+|/add`
    }
    Ul{
        children: moviePage.state.movies.map(entry => (Li{
            NavLink.(entry.title){ href: `/view/${entry.idx}` }
        }))
    }
}

After the movies property, current route information is captured from ClientNavigation:

const routeInfo = ClientNavigation.currentRoute()

ClientNavigation is part of live-web.clientrouter module, and currentRoute() will capture any parameters or properties sent to the route. In this case, we are interested in extracting the id parameter in /view/:id:

if ( routeInfo.data && routeInfo.data.id ){
    this.current = this.movies.find(m => m.idx === parseInt(routeInfo.data.id ))

Based on the id parameter, we search the list of movies for the one with the requested id. If it's found, the current property is set, which will notify the main view:

Div{ classes: ['main']
    H1{ T{ text: moviePage.state.current?.title ? moviePage.state.current.title : ''}}
    P{ T{ text: moviePage.state.current?.description ? moviePage.state.current?.description : ''} }
    // ...
}

If there are any errors, we set the error property, which will notify the error modal:

Div{ classes: ['modal', moviePage.state.error ? 'show' : '']
    Div{ classes: ['error-message'] T{ text: moviePage.state.error } }
    on click: () => moviePage.state.error = ''
}

In MoviePageState there's also a method to be implemented: removeCurrentEntry(). The method is triggered from the delete button when it gets pressed. We will come back to this method later.

Now that we created the MainPage component, we need to add it as a route in app/Home.lv:

import live-web.dom
import live-web.clientrouter
import live-elements-web-server.view

import .app.pages

component Home < PageView{

    head: PageProperties{
        title: "Live Elements"
        StyleLink{ href: '/styles/style.css' }
    }

    Body{
        Router{
            RouteSwitch{
                Route{ path: '/view/:id' component: MoviePage }
            }
        }
    }
}

There's one more thing needed for our route to work properly. Currently, the server will only serve the Home component when we acccess the route (/). We will need to let the server know that the Home component can be accessed via any route ('/*'). To do this, we need to update bundle/bundle.lv:

import live-elements-web-server.bundle
import live-elements-web-server.router
import live-elements-web-server.style
import .app

import { readDb, writeDb } from '../db/db.mjs'

instance bundle Bundle{
    Stylesheet{ src: './styles/style.css' output: 'style.css' }
    
    GetRoute{ url: '/api/list' f: async(req, res) => {
        // ...
    }}
    
    PostRoute{ url: '/api/add' f: async(req, res) => {
        // ...
    }}

    PostRoute{ url: '/api/update/:id' f: async(req, res) => {
        // ...
    }}

    PostRoute{ url: '/api/remove' f: async(req, res) => {
        // ...
    }}

    // serve any route to Home
    ViewRoute{ url: '/*' c: Home }
}

We moved the declaration of our route all the way down the list of routes to the end. This is because we want our server to match all the routes defined before it, and only match everything else against the Home component when all the other defined routes don't match.

Now, if we start the server (lvweb serve), and access localhost:8080/view/1, we should now see our movie being displayed.We can navigate to other movies using the links in the sidebar. You'll notice this is all done without a page refresh.

It's time to add the index route as well (/). Since MoviePage already checks if there's a movie parameter given at the route, all we need to do is add the route to Home.lv:

import live-web.dom
import live-web.clientrouter
import live-elements-web-server.view

import .app.pages

component Home < PageView{

    head: PageProperties{
        title: "Live Elements"
        StyleLink{ href: '/styles/style.css' }
    }

    Body{
        Router{
            RouteSwitch{
                Route{ path: '/view/:id' component: MoviePage }
                Route{ path: '/' component: MoviePage }
            }
        }
    }
}

localhost:8080 route should now show the last entry in our list.

Adding a new entry

The sidebar in MoviePage has an add button at the top:

Div{ classes: ['sidebar']
    H2{ 
        T{ text: 'Movies' } 
        A`+|/add` // this is equal to writing A{ href: '/add' T`+` }
    }
    // ...
}

It points to the /add route, but the route is currently not available. Let's create it. We'll need to create the MovieAddPage component, which will show a form to add a new movie entry. The component will also have a state associated. The state is quite simple, we only need to know if there's any request loading, or if there's an error when submitting the form.

We'll store the component in app/pages/MovieAddPage.lv:

import live-web.dom
import live-web.model
import live-web.clientrouter

import ky from 'ky'

component MovieAddPageState < State{
    boolean isLoading: false
    string error: ''

    fn add(title:string, description:string){
        const data = {title, description}
        this.isLoading = true
        ky.post('/api/add', {json: data}).json()
            .then(data => {
                this.isLoading = false
                if ( data.error ){
                    this.error = data.error
                } else {
                    ClientNavigation.goTo('/')
                }
            })
            .catch(error => {
                this.isLoading = false
                this.error = error
            })
    }
}

component MovieAddPage < Div{
    id: movieAddPage
    classes: ['container']

    MovieAddPageState state: MovieAddPageState{}

    Div{ classes: ['main']
        H1`New entry`
        Form{ props = {action: '/submit-movie-url', method: 'post'}
            Div{ classes: ['form-group']
                Label{ props: ({for: 'movie-title'})
                    T`Movie Title:`
                }
                Input{
                    id: movieTitle
                    glid: 'movie-title'
                    props = {type: 'text', name: 'movieTitle', required: ''}
                }
            }
            Div{ classes: ['form-group']
                Label{ props: ({for: 'movie-description'})
                    T`Description:`
                }
                TextArea{ id: movieDescription
                    glid: 'movie-description'
                    props = {name: 'movieDescription', required: ''}
                }
            }
            Button{ 
                classes: ['submit-btn'] 
                props = {type: 'submit'}
                on click: (e) => {
                    e.preventDefault()
                    movieAddPage.state.add(movieTitle.currentValue, movieDescription.currentValue)
                }

                T`Add Movie`
                
            }
        }
    }

    Div{ classes: ['modal', movieAddPage.state.isLoading ? 'show' : '']
        Div{ classes: ['spinner'] }
    }

    Div{ classes: ['modal', movieAddPage.state.error ? 'show' : '']
        Div{ classes: ['error-message'] T{ text: movieAddPage.state.error } }
        on click: () => movieAddPage.state.error = ''
    }
}

The state has the add method, which does a post request to /api/add with the form data gathered when clicking the form submit button. If the request succeeds ClientNavigation is used to redirect the user to the index page. If the request fails, the error property is set, which will trigger the error modal to show automatically:

Div{ classes: ['modal', movieAddPage.state.error ? 'show' : '']
    Div{ classes: ['error-message'] T{ text: movieAddPage.state.error } }
    on click: () => movieAddPage.state.error = ''
}

We can now add our route to app/Home.lv:

import live-web.dom
import live-web.clientrouter
import live-elements-web-server.view

import .app.pages

component Home < PageView{

    head: PageProperties{
        title: "Live Elements"
        StyleLink{ href: '/styles/style.css' }
    }

    Body{
        Router{
            RouteSwitch{
                Route{ path: '/' component: MoviePage }
                Route{ path: '/view/:id' component: MoviePage }
                Route{ path: '/add' component: MovieAddPage }
            }
        }
    }
}

And the add (+) button in the sidebar should now work as expected, and allow us to add new movie entries via the form.

Edit

When viewing a movie in MainPage there's an edit button, which redirects the user to /edit/:id:

Button{ 
    classes: ['edit-btn', moviePage.state.current ? '' : 'hidden'] 
    on click: () => { ClientNavigation.goTo(`/edit/${moviePage.state.current.idx}`) }
    T`Edit` 
}

We will need a new component to edit a movie entry. The component will be very similar to the MovieAddPage, with a few changes:

  • The data for the movie being edited needs to be downloaded when the page loads.
  • The form fields need to initialize with the downloaded data
  • The post request is at /api/update/:id
import live-web.dom
import live-web.model
import live-web.clientrouter

import ky from 'ky'

component MovieEditPageState < State{
    string title: ''
    string description: ''
    number idx: null
    boolean isLoading: false
    string error: ''

    fn update(title:string, description:string){
        const data = {title, description}
        this.isLoading = true
        ky.post(`/api/update/${this.idx}`, {json: data}).json()
            .then(data => {
                this.isLoading = false
                if ( data.error ){
                    this.error = data.error
                } else {
                    ClientNavigation.goTo('/')
                }
            })
            .catch(error => {
                this.isLoading = false
                this.error = error
            })
    }

    fn completed(){
        this.isLoading = true
        ky.get('/api/list').json()
            .then(data => {
                const routeInfo = ClientNavigation.currentRoute()
                if ( routeInfo.data && routeInfo.data.id ){
                    const current = data.find(m => m.id === parseInt(routeInfo.data.id ))
                    this.title = current.title
                    this.description = current.description
                    this.idx = current.id
                }
                this.isLoading = false
            })
            .catch (error => {
                this.error = error
                this.isLoading = false
            })
    }
}

component MovieEditPage < Div{
    id: movieEditPage
    classes: ['container']

    MovieEditPageState state: MovieEditPageState{}

    Div{ classes: ['main']
        H1`Edit entry`
        Form{ props = {action: '/submit-movie-url', method: 'post'}
            Div{ classes: ['form-group']
                Label{ props: ({for: 'movie-title'})
                    T`Movie Title:`
                }
                Input{
                    id: movieTitle
                    props = {name: 'movieTitle', required: ''}
                    glid: 'movie-title'
                    type: 'text'
                    value: movieEditPage.state.title
                }
            }
            Div{ classes: ['form-group']
                Label{ props: ({for: 'movie-description'})
                    T`Description:`
                }
                TextArea{ 
                    id: movieDescription
                    glid: 'movie-description'
                    props = {name: 'movieDescription', required: ''}
                    value: movieEditPage.state.description
                }
            }
            Button{ 
                classes: ['submit-btn'] 
                props = {type: 'submit'}
                on click: (e) => {
                    e.preventDefault()
                    movieEditPage.state.update(movieTitle.currentValue, movieDescription.currentValue)
                }

                T`Update`
            }
        }
    }

    Div{ classes: ['modal', movieEditPage.state.isLoading ? 'show' : '']
        Div{ classes: ['spinner'] }
    }

    Div{ classes: ['modal', movieEditPage.state.error ? 'show' : '']
        Div{ classes: ['error-message'] T{ text: movieEditPage.state.error } }
        on click: () => movieEditPage.state.error = ''
    }
}

The state has idx, title and description properties additional to the ones in MovieAddState. They are loaded when the state is completed, similar to how we load data in MoviePageState. Both form fields, movieTitle and movieDescription, bind their value to the 2 state properties: title and description:

Input{
    id: movieTitle
    // ...
    value: movieEditPage.state.title
}
// and
TextArea{
    id: movieDescription
    // ...
    value: movieEditPage.state.description
}

The form submit button, simply triggers the update() method from MovieEditPageState, which will post the data to the server, then redirect the user to the index page.

Let's add the route to app/Home.lv:

import live-web.dom
import live-web.clientrouter
import live-elements-web-server.view

import .app.pages

component Home < PageView{

    head: PageProperties{
        title: "Live Elements"
        StyleLink{ href: '/styles/style.css' }
    }

    Body{
        Router{
            RouteSwitch{
                Route{ path: '/' component: MoviePage }
                Route{ path: '/view/:id' component: MoviePage }
                Route{ path: '/add' component: MovieAddPage }
                Route{ path: '/edit/:id' component: MovieEditPage }
            }
        }
    }
}

Removing an entry

The last step is to remove an entry. We've already set up the remove button, so all we need to do is implement the method inside the HomePageState component. This will be inside (app/pages/MoviePage.lv):

import live-web.dom
import live-web.model
import live-web.clientrouter

import ky from 'ky'

component MovieState < State{
    // ...
}

component MoviePageState < State{
    MovieState current: null
    Array<MovieState> movies: []
    boolean isLoading: false
    string error: ''

    fn removeCurrentEntry(){
        if ( !this.current )
            return
        
        this.isLoading = true
        ky.post('/api/remove', {json: {id: this.current.idx}}).json()
            .then(data => {
                this.isLoading = false
                if ( data.error ){
                    this.error = data.error
                } else {
                    ClientNavigation.goTo('/')
                }
            }).catch(error => {
                this.isLoading = false
                this.error = error
            })
    }

    fn completed(){
        // ...
    }
}

component MoviePage < Div{
    // ...
}

removeCurrentEntry does a post to api/remove and if the request succeeds, it redirects the user to the index page (/).

Page not found

Before we wrap up, we should cover the case when a page is not found. We can do this by creating a PageNotFound commponent in app/pages/PageNotFound.lv:

import live-web.dom

component PageNotFound < Div{
    H1`404 Error: The page you have requested was not found.`
}
~

And add it to the client router:

import live-web.dom
import live-web.clientrouter
import live-elements-web-server.view

import .samples.tutorialroutes.app.pages

component Home < PageView{

    head: PageProperties{
        title: "Live Elements"
        StyleLink{ href: '/styles/style.css' }
    }

    Body{
        Router{
            RouteSwitch{
                Route{ path: '/' component: MoviePage }
                Route{ path: '/view/:id' component: MoviePage }
                Route{ path: '/add' component: MovieAddPage }
                Route{ path: '/edit/:id' component: MovieEditPage }
                Route{ path: '*' component: PageNotFound }
            }
        }
    }
}

Summary

This wraps up our tutorial on routes and creating an app to manage a movie collection. Hopefully you enjoyed the workflow with Live Elements. We've covered:

  • Creating an api using the bundle file.
  • Creating a client router
  • Working with states and state changes