Architecture

Let's take a look at the example how you can build your application using Akili.

In the project, we use:

File structure and description

  • /public/assets - folder for accessing the statics from the browser
  • /src - all client side code
  • /src/main.js - entry point of the frontend
  • app.js - run the server

app.js contains a simple implementation of the necessary things for the server-side rendering.

The structure of the frontend consists of three main parts:

  • components - folder with the universal components that you can use many times in the application.
  • controllers - folder with the certain components responsible for the logic of the application: the routing and distribution of data.
  • actions - folder with functions to get and save data.
And the secondary:
  • fonts - public folder with fonts
  • img - public folder with images
  • styles - public folder with styles
However, the three folders above with the static are not unique and any component could have its own.

The universal (simple) component is completely independent. We pass data to it through the attributes, and we get the result back using the events. It should not work with the store. This is the task of the controllers to distribute the business data. The controller is the layer between the storage and the simple components.

src/main.js

The best way to register the component is static method .define(). We make it for every component and call at the entry point.

import App from './controllers/app/app';
import Posts from './controllers/posts/posts';
import PostEdit from './controllers/post-edit/post-edit';
import Users from './controllers/users/users';
import PostCards from './components/post-cards/post-cards';
import PostForm from './components/post-form/post-form';
import UserCards from './components/user-cards/user-cards';

App.define();
Posts.define();
PostEdit.define();
Users.define();
PostCards.define();
PostForm.define();
UserCards.define();

To make ajax requests we use request service.

import request, { Request } from 'akili/src/services/request';
request.addInstance('api', new Request('/api', { json: true }));

By default, the request object itself is an instance of the Request class. And you could make any requests through it. But it is much more convenient to create a separate instance with its own settings for each direction of the requests. In this case, we created a separate one to work with /api endpoint.

Now we can use it anywhere importing only request, for example:

request.use.api.get('/posts').then(res => console.log(res.data));

The request will be sent to /api/posts, with json content type and we get an object instead of a string in the response.

Another lines in the file:

import store from 'akili/src/services/store';
window.addEventListener('state-change', () => store.loader = true);
window.addEventListener('state-changed', () => store.loader = false);

Let's start with store. This is the repository of our application. You can store any data here. In this case, store is automatically synchronized with all the places where you need the changes. You just need to change the necessary property. In the lines above during certain events we change loader property. The component that displays the preloader is subscribed to this store property and will get the changes.

state-change and state-changed events are not system for window. They are triggered by the router. The first, before any change in the address bar of the browser. The second, immediately after. We need this to show and hide the preloader. About this later.

The router and the framework will be initialized after the DOM loading.

document.addEventListener('DOMContentLoaded', () => {
  router.init('/app/posts', false);
  Akili.init().catch((err) => console.error(err));
});
src/controllers/app/app.js

This file describes the root controller. Here we specify the first level of the router states to display the header and entry point of the nested routes.

import './styles/app.scss'
import Akili from 'akili';
import router from 'akili/src/services/router';

export default class App extends Akili.Component {
  static template = require('./app.html');

  static define() {
    Akili.component('app', this);

    router.add('app', '^/app', {
      component: this,
      title: 'Akili example site'        
    });
  }

  compiled() {
    this.store('loader', 'showLoader');
    this.store('posts', posts => this.scope.post = posts.find(p => p.selected));
  }
}

Let's go through the code above. At first, we load the styles of the component. All static files: styles, images, fonts of a particular component are stored in its personal folder, not the general one.

Then it is the declaration of the component. .define() method is not required, but it is a very simple way to configure every component. Here we perform all the necessary actions and then call the method in the project entry point.

Akili.component('app', this);

The line above registers the component and we can use it later. After that, the state is added to the router and so on.

.compiled() is one of the component lifecycle methods that is called after the compilation. There are two subscriptions to the store. We spoke about one of them earlier.

this.store('loader', 'showLoader');

In this line we link store.loader of the store and scope.showLoader of the current component. By default, connection is created in both directions. If store.loader changes then we get changes in scope.showLoader and vice versa.

src/controllers/app/app.html

Here is the template for App component/controller. We specified it as a static template property in the component.

static template = require('./app.html');

Let's pay attention to an interesting piece in the template:

<img 
  src="./img/logo.svg" 
  width="60" 
  class="d-inline-block align-middle mr-1 ${ utils.class({loader: this.showLoader}) }"
>

It is a logo and a preloader at the same time. If you add loader class to the element, the image will start to rotate. As you remember, in src/main.js we subscribed to two events. Before changing the address bar we change store.loader to true. At this point, the scope.showLoader of App component is true as well and utils.class ({loader: this.showLoader}) expression will return loader class. After the loading everything will change to false and the class will disappear.

Another important piece:

<div class="container pb-5">
  <route></route>
</div>

route is a component into which the template of the corresponding route level is loaded. In this case it is the second level. Any route-heir from app will be loaded exactly here. And app itself was loaded in route, which was specified at the beginning of body in /public/main.html.

src/controllers/posts/posts.js

Here is the component/controller of the posts.

import Akili from 'akili';
import router from 'akili/src/services/router';
import store from 'akili/src/services/store';
import { getAll as getPosts } from '../../actions/posts';

export default class Posts extends Akili.Component {
  static template = require('./posts.html');

  static define() {
    Akili.component('posts', this);

    router.add('app.posts', '/posts', {
      component: this,
      title: 'Akili example | posts',
      handler: () => getPosts()
    });
  }

  created() {
    this.scope.selectPost = this.selectPost.bind(this);
    this.scope.deletePost = this.deletePost.bind(this);
  }

  compiled() {
    this.store('posts', 'posts');
  }

  selectPost(id) {
    this.scope.posts.forEach(post => post.id == id? (post.selected = true): delete post.selected);
  }

  deletePost(id) {
    this.scope.posts.forEach((post, i, arr) => post.id == id && arr.splice(i, 1));
  }
}

Much of this you already know, but there are some new points. For example, to specify the nesting state we use dot in the name: app.posts. Now posts are inherited from app.

Also, when declaring the state we specified a handler function. It will be called if user hits the appropriate url. A special object is passed to the function as an argument where all information about the current transition is stored. Anything that we return in this function will also be stored in this object. The link to the transition object is in this.transition.

In the example above we took the data from the store:

this.store('posts', 'posts');

It is because of the function .getPosts() saved it there at the same time. But we could take it from the transition:

this.scope.posts = this.transition.data;

You can see this way in users controller.

Note that the component methods are not in the scope of its template. To call a function in the template you have to add it to the template scope:

this.scope.selectPost = this.selectPost.bind(this);
this.scope.deletePost = this.deletePost.bind(this);
src/controllers/posts/posts.html

This is the template of the posts. It needs to display the list of the posts. But this component is a controller. The list of the posts is something universal and we need to be able to use it anywhere. Therefore, it is placed in the separate component in src/components/post-cards/.

<post-cards
  data="${ this.filteredPosts = utils.filter(this.posts, this.filter, ['title', 'body']) }"
  on-select="${ this.selectPost(event.detail) }"
  on-delete="${ this.deletePost(event.detail) }"
></post-cards>

Now we just pass the necessary array to PostCards component and it will display everything. But we also have a search input here.

<input class="form-control" placeholder="search..." on-debounce="${ this.filter = event.target.value }">
<if is="${ !this.filteredPosts.length }">
 <p class="alert alert-warning">Not found anything</p>
</if>

Therefore, we pass the data (this.posts) filtered. on-debounce event is custom. It occurs with a delay from the last keystroke. We could use standard on-input, but it is less productive with a lot of data.

When data is changed inside, PostCards triggers custom on-select and on-delete events. Processing it we store the changes.

src/controllers/post-edit/post-edit.js

Here is the component/controller of the post editing page. It makes no sense to analyze all the code because in the examples above almost everything is the similar. Let's dwell on the difference:

router.add('app.post-edit', '/post-edit/:id', {
  component: this,
  title: transition => `Akili example | ${ transition.path.data.title }`,
  handler: transition => getPost(transition.path.params.id)
});

We specified the dynamic parameter id in this route. So in the handler function we have access to its value in transition.path.params.id. It is the identifier of the required post.

src/controllers/post-edit/post-edit.html

As well as with the list of posts, here we put the form in the separate PostForm component to be able to use it many times.

<post-form post="${ this.post }" on-save="${ this.savePost(event.detail) }"></post-form>
src/components/post-form/post-form.html

Let's look at this component. Pay attention to the comments:

/**
  * Universal component to display a post form
  *
  * {@link https://akilijs.com/docs/best#docs_encapsulation_through_attributes}
  *
  * @tag post-form
  * @attr {object} post - actual post
  * @scope {object} post - actual post
  * @message {object} post - sent on any post property change 
  * @message {object} save - sent on form save
  */

It is js-doc with some custom tags.

  • @tag - name of the component
  • @selector - complete selector of the elements that will be wrapped by this component.
  • @attr - attribute for transferring data to the component from the parent
  • @scope - scope property of the component
  • @message - message that is sent when a custom event is triggered

Comments in the source code of the framework are written in the same style.

compiled() {
  this.attr('post', 'post'); 
}

In the code snippet above we created a link between the post attribute and the post scope property of the component. If we fill this attribute with some value then we immediately get changes in scope.post. And if we change scope.post in the component then on-post event will be automatically triggered.

<post-form post="${ this.parentPost }" on-post="${ this.parentPost = event.detail }">

If we wrote html above somewhere we would have a double connection between the parent scope.parentPost and the current scope.post.

But our form works a little differently. We need to save the changed post only at the click of the button and not at every change. Therefore, we use our own event:

static events = ['save'];
save() {
  this.attrs.onSave.trigger(this.scope.post);
}

In the first line we registered the custom event. .save() method is called when the button on the form is clicked. Here we trigger save event and send the changed post.

<post-form post="${ this.post }" on-save="${ this.savePost(event.detail) }"></post-form>

It is the code in the template of PageEdit controller. We passed the post through the post attribute to the PostForm component and get the changed one back by processing on-save.

src/actions

Actions are just functions for retrieving and storing data. For cleanliness and convenience they are placed in the separate folder. For example, src/actions/posts.js:

import request from 'akili/src/services/request';
import store from 'akili/src/services/store';

export function getAll() {
  if(store.posts) {
    return Promise.resolve(store.posts);
  }

  return request.use.api.get('/posts').then(res => store.posts = res.data);
}

export function getPost(id) {
  return getAll().then(posts => {
    let post = posts.find(post => post.id == id);

    if(!post) {
      throw new Error(`Not fount post with id "${id}"`);
    } 
  
    return post;
  });
}

export function updatePost(post) {
  return request.use.api.put(`/posts/${post.id}`, { json: post }).then(res => {
    store.posts = store.posts.map(item => item.id == post.id? {...item, ...post}: item);
    return res.data;
  });
}

Everything is simple enough. There are three functions: to get a list of posts, to get a specific post and to update the post.

Conclusion

We will not consider files with user components, because there almost all logic is similar to the above.

What can you get using Akili?

  • A powerful and intuitive component system that lets you erase the line between markup and the application logic. In addition, it can easily wrap any third-party module. Whether it's tabs, drag & drop items and stuff.
  • Storage to save and share data between your application components.
  • Routing that supports all the necessary functionality: inheritance, dynamic data, templates, working with hash and without it, changing document.title, resolving data, abstract routes, redirects and much more.
  • Ability to make ajax requests. You can create different instances of this with their own settings. There is a caching system. sending of any data types, files, etc.
  • Server-side rendering.
  • Absence of everything that does not provide for html and javascript by default. No magic add-ins to markup or code.

That's it. The example site is here, the source code is here.