Sample Simple Notes SPA at Mithril.js

Mithril.js is an unpopular tool for building client web applications. There are practically no publications on Habré on this topic. In this post, I want to show how you can make a small application on Mithril. The application will be based on this publication ( translation )

What is Mihtril


Mithril is a reactive framework designed to create SPA (single-page web applications). All in all, it's just javascript and 13 signatures of API functions. In addition, there is a mithril-stream library that is not included in mithril and is used separately. The core of mithril includes routing the application and working with XHR requests. The central concept is abstraction - a virtual node (vnode). A virtual node is just a js object with some set of attributes. Virtual nodes are created by the special function m (). The current state of the interface is stored in a list of virtual nodes (virtual DOM). At the initial rendering of the application page, the virtual DOM is translated into the DOM. When the DOM API event handler starts, when the m.request () promise is completed, and when the URL changes (navigation along the application’s routes), a new virtual DOM array is generated,compares with the old, and the changed nodes change the browser DOM. In addition to DOM events and the completion of the m.request () request, redrawing can be called manually with the m.redraw () function.

There are no HTML-like templates in mithril out of the box, there is no JSX support, although you can use all of this with various plugins to build if you wish. I will not use these opportunities here.

If the first argument to m () is a string, (for example, 'div'), then the function returns a simple virtual node and as a result, an HTML tag will be displayed in the DOM

<div></div>

If the first argument to m () is the object or function that returns the object, then such an object must have a view () method, and such an object is called a component. The component's view () method, in turn, should always return the m () function (or an array of the type: [m (),]). Thus, we can build a hierarchy of component objects. And it’s clear that ultimately all components return simple vnode nodes.

Both virtual nodes and components have life-cycle methods, and they are called the same oninit (), oncreate (), onbeforeupdate (), etc. Each of these methods is called at a very specific point in time in the page rendering.

You can pass parameters to the virtual node or component as an object, which should be the second argument to the m () function. You can get a link to this object inside the node using the vnode.attrs notation. The third argument to the m () function is the descendants of this node and can be accessed via the vnode.children link. In addition to the m () function, simple nodes are returned by the m.trust () function.

The author of mithril does not offer any special application design patterns, although he advises to avoid some unsuccessful decisions, for example, “too thick” components or manipulating the descendants of the component tree. The author also does not offer special ways or methods to control the state of the application as a whole or components. Although the documentation states that you should not use the state of the node itself, manipulate it.

All these features of mithril seem very inconvenient, and the framework seems to be unfinished, there are no special recommendations, there is no state / storage, there is no reducer / event dispatcher, no templates. In general, do as you can.

What you need for an example


We will use:


The frontend server is not important here, it just has to give the client index.html and script and style files.

We will not install mithril in node_modules, and bind the application code and the framework into one file. The application code and mithril will be uploaded to the page separately.

I will not describe the installation procedure for the tools, although I can say about postgREST, just download the binary file, put it in a separate folder, create a test.conf configuration file like this:

db-uri = "postgres://postgres:user1@localhost:5432/testbase"
server-port= 5000
# The name of which database schema to expose to REST clients
db-schema= "public"
# The database role to use when no client authentication is provided.
# Can (and probably should) differ from user in db-uri
db-anon-role = "postgres" 

At the same time, your postgesql cluster should have a testbase base and user1. In this test base, create a table:

-- Postgrest sql notes table 
create table if not exists notes (
id serial primary key,
title varchar(127) NOT NULL,
content text NOT NULL,
created timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed timestamp with time zone,
ddel smallint default 0
)

Starting the postgREST server is done with the command:

postgrest test.conf

After starting the server, it displays informational messages about connecting to the database, and on which port it will listen to clients.

Planning a project


So, if it’s roughly understood how mitril works, you need to figure out how to make the application. Here is the plan:

  1. We will store application data in a local object, let's call its model
  2. API applications will be stored in a separate file
  3. We will store application routes in a separate file
  4. The path file will be the entry point for building the application.
  5. Each separate component (and I will use the component rendering scheme) and the functions associated with it will be stored in a separate file
  6. Each component that renders model data will have access to the model.
  7. Component DOM event handlers are localized in the component

Thus, we do not need custom events, rather native DOM events, callbacks for these events are localized in the components. I will use two way binding. Perhaps not everyone likes this approach, but not everyone likes redux or vuex. Moreover, the one way binding technique can also be elegantly implemented in mitril using mithril-sream. But in this case, this is a redundant solution.

Application Folder Structure




The public folder will be served by the front server, there is an index.html file, and directories with styles and scripts.

The src folder contains the root of the router and the API definition, and two directories, for the model and views.

At the root of the project there is a rollup.config configuration file, and the project is built using the command:

rollup –c

In order not to bore the reader with the long chunks of code that is available on github.com, I will only comment on the main implementation elements to demonstrate an idiomatic approach for mitral.

API and router


API Code:

// used by backend server
export const restApi= {
  notes: { url: 'notes', editable: ['add', 'edit', 'del'] }
}

// used by routers
export const appApi = {
  root: "/",
}
// used by navBar
// here array of arrays though it may be hash eg
export const appMenu = [
  [`${appApi.root}`, 'Home'],
  [`${appApi.root}`, 'About'],
  [`${appApi.root}`, 'Contacts'],
]

I defined an API for the REST server and for the router.

Router:

import { restApi, appApi } from './appApi';
import { moModel } from './model/moModel';
import { vuView, vuApp } from './view/vuApp';
import { vuNavBar } from './view/vuNavBar';

// application router
const appRouter = { [appApi.root]: {
  render() {
    const view = m(vuApp, {
      model: moModel.getModel( restApi.notes ),
    });
    return vuView( {menu: vuNavBar}, view);
  }
}};

// once per app
m.route(document.body, "/", appRouter);

Here the router is mounted on the document body. The router itself is an object that describes the routes and components that will be used on this route, the render () function should return vnode.

The appApi object defines all valid application routes, and the appMenu object defines all possible navigation elements for the application.

The render function, when called, generates an application model and passes it to the root node.

Application model


The structure that stores relevant data, I called the model. The source code of the function that returns the model:

getModel(
    {url=null, method="GET", order_by='id', editable=null} = {}
  ) {
/**
  * url - string of model's REST API url
  * method - string of model's REST method
  * order_by - string "order by" with initially SELECT 
  * editable - array defines is model could changed
*/
    const model = {
      url: url,
      method: method,
      order_by: order_by,
      editable: editable,
      key: order_by, // here single primary key only
      list: null, // main data list 
      item: {}, // note item
      error: null, // Promise error
      save: null, // save status
      editMode: false, // view switch flag
      word: '' // dialog header word
    };  
    model.getItem= id => {
      model.item= {};
      if ( !id ) {
        model.editMode= true;
        return false;
      }
      const key= model.key;
      for ( let it of model.list ) {
        if (it[key] == id) {
          model.item= Object.assign({}, it);
          break;
        }
      }
      return false;
    };
    return model;
  },

Here it is initialized, and return the model object. The links inside the object may change, but the link to the object itself remains constant.

In addition to the getModel function, the global moModel object has wrapper functions for the m.request () mitry function, these are getList (model), and formSubmit (event, model, method). The model parameter is actually a reference to the model object, event is the event object that is generated when the form is submitted, method is the HTTP method with which we want to save the note (POST is a new note, PATCH, DELETE is old).

Representation


The view folder contains functions that are responsible for rendering individual page elements. I divided them into 4 parts:

  • vuApp - the root component of the application,
  • vuNavBar - navigation bar,
  • vuNotes - a list of notes,
  • vuNoteForm - note editing form,
  • vuDialog - HTML dialog element

The router determines that vuView (menu, view) is returned on a single route.

Definition of this function:

export const vuView= (appMenu, view)=> m(vuMain, appMenu, view);

This is just a wrapper that returns the vuMain component, if the appMenu object is complex enough to have nested objects in structure similar to an external object, then such a wrapper is an appropriate way to return a component with different navigation elements and child components (you just need to write less code) .

VuMain Component:

const vuMain= function(ivnode) {
  // We use ivnode as argument as it is initial vnode
  const { menu }= ivnode.attrs;
  return { view(vnode) {
    // IMPORTANT !
    // If we use vnode inside the view function we MUST provide it for view
    return [
      m(menu),
      m('#layout', vnode.children)
    ];
  }};
};

This simply returns the vnodes of navigation and the actual content of the page.

Hereinafter, where it is possible to define a component, I will use closures. Closures are called once during the initial rendering of the page, and locally store all the passed links to objects and definitions of their own functions.

Closing as a definition should always return a component.

And actually the application content component:

export const vuApp= function(ivnode) {
  const { model }= ivnode.attrs;
  //initially get notes
  moModel.getList( model );
  
  return { view() { 
    return [
      m(vuNotes, { model }),
      m(vuNoteForm, { model }),
      vuModalDialog(model)
    ];
  }};
}

Since we already have a model, when I call a closure, I want to get the entire list of notes from the database. There will be three components on the page:

  • vuNotes - a list of notes with the add button,
  • vuNoteForm - note editing form,
  • vuModalDialog - a dialog element that we will show modally, and slam when necessary.

Since each of these components needs to know how to draw itself, we pass a link to the model object in each.

Component Note List:

//Notes List
export const vuNotes= function(ivnode) {
  const { model }= ivnode.attrs;
  const _display= ()=> model.editMode ? 'display:none': 'display:block';
  const vuNote= noteItem(model); // returns note function
  
  return { view() {
    return model.error ? m('h2', {style: 'color:red'}, model.error) :
    !model.list ? m('h1', '...LOADING' ) :
    m('div', { style: _display() }, [
      m(addButton , { model } ),
      m('.pure-g', m('.pure-u-1-2.pure-u-md-1-1',
        m('.notes', model.list.map( vuNote ) )
      ))
    ]);
  }};
}

The bool editMode flag is stored in the model object; if the flag value is true, then we show the editing form, otherwise - a list of notes. You can raise the check one level higher, but then the number of virtual nodes and the DOM nodes themselves would change each time the flag is switched, and this is unnecessary work.

Here we are idiomatic for mitril, we generate a page, checking the presence or absence of attributes in the model using ternary operators.

Here is the closure that returns the note display function:

const noteItem= model=> {
  // click event handler
  const event= ( msg, word='', time=null)=> e=> {
    model.getItem(e.target.getAttribute('data'));
    if ( !!msg ) {
      model.save= { err: false, msg: msg };
      model.word= word;
      if ( !!time )
        model.item.completed= time;
      vuDialog.open();
    } else {
      model.editMode= true;
    }
  };
  // trash icon's click handler
  const _trash= event('trash', 'Dlelete');
  
  // check icon's click handler
  const _check= event('check', 'Complete',
    // postgre timestamp string
    new Date().toISOString().split('.')[0].replace('T', ' '));
  
  // edit this note
  const _edit= event('');
  
  const _time= ts=> ts.split('.')[0];
  
  // Single Note 
  const _note= note=> m('section.note', {key: note.id}, [
    m('header.note-header', [ m('p.note-meta', [
      // note metadata
      m('span', { style: 'padding: right: 3em' }, `Created: ${_time( note.created )}`),
      note.completed ? m('span', `  Completed: ${_time( note.completed )}`) : '', 
      // note right panel 
      m('a.note-pan', m('i.fas.fa-trash', { data: note.id, onclick: _trash } )),
      note.completed ? '' : [
        m('a.note-pan', m('i.fas.fa-pen', {data: note.id, onclick: _edit } )),
        m('a.note-pan', m('i.fas.fa-check', { data: note.id, onclick: _check} ))
      ]
    ]),
      m('h2.note-title', { style: note.completed ? 'text-decoration: line-through': ''}, note.title)
    ]),
    m('.note-content', m('p', note.content))
  ]);
  return _note;
}

All click handlers are defined locally. The model does not know how the note object is structured, I only defined the key property, with which we can select the desired element from the model.list array. However, the component must know exactly how the object it draws is structured.

I will not give the full text of the code for the form for editing the note; we just look at the form submission handler separately:

// form submit handler
  const _submit= e=> {
    e.preventDefault();
    model.item.title= clean(model.item.title);
    model.item.content= clean(model.item.content);
    const check= check_note(model.item);
    if ( !!check ) {
      model.save= { err: true, msg: check }; 
      model.word= 'Edit';
      vuDialog.open();
      return false;
    } 
    return moModel.formSubmit(e, model, _method() ).then(
      ()=> { model.editMode=false; return true;}).catch(
      ()=> { vuDialog.open(); return false; } );
  };

Since the definition occurs in the closure, we have a link to the model, and we return a promise with the subsequent processing of the result: a ban on showing the form when the request is completed normally, or in case of an error - opening a dialog with the error text.

Each time you access the backend server, the list of notes is reread. In this example, there is no need to adjust the list in memory, although this can be done.

The dialog component can be viewed in the repository , the only thing that needs to be emphasized is that in this case I used an object literal to define the component, because I want the window opening and closing functions to be available to other components.

Conclusion


We wrote a small SPA application in javascript and mithril.js, trying to stick with the idioms of this framework. I want to pay attention once again that this is just javascript code. Maybe not quite clean. The approach allows you to encapsulate small pieces of code, isolate the mechanics of a component, and use a common state for all components.

All Articles