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
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 entriesPOST /api/add
to add a new entryPOST /api/update/:id
to update an entryPOST /api/remove/:id
to remove an entryimport 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.
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 entryWe'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
.
Inside app/pages/MoviePage.lv
file, we will define the following:
component MovieState < State{
number idx: 0
string title: ''
string description: ''
}
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.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.
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.
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:
/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 }
}
}
}
}
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 (/
).
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 }
}
}
}
}
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: