Architecture

Let's take a look at the example of an application architecture that you can write using Akili.

In the project, we use:

File structure and description

/public/assets - folder for accessing the statics from the browser
/src - frontend's folder
/src/main.js - entry point of the frontend
app.js - runs the server

Generally, there is no backend in the example. app.js contains a simple implementation of static files returning and a couple rows for server-side rendering.

Demo data is taken from https://jsonplaceholder.typicode.com/.

The structure of the frontend consists of three main parts:

  • components - folder with universal components that you can use many times in the application.
  • controllers - folder with unique components responsible for the logic of the application: the routing and distribution of data.
  • actions - folder with functions to get and save data.
And three 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 each component could have its own.

The universal (simple) component is completely independent. Data is passed to it through attributes, and we get the result back through events. It should not work with the store. This should be done by the controller components. The controller is the layer between the storage and the simple components.

src/main.js

The best way to register a component in the framework is static method .define(). We do it for each component and call them 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('https://jsonplaceholder.typicode.com', { 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 requests. In this case, we created a separate one for working with jsonplaceholder.typicode.com api.

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 https://jsonplaceholder.typicode.com/posts, with json content type, and we get an object, instead of the 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, to which one of the components that displays the preloader is subscribed.

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

Next, the router and the framework are initialized after loading the DOM.

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 routing to display the header and entry point for the nested routes in the template.

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. 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.

Next is the declaration of the component. .define() method is not required, but it is a very simple way to configure each component. Here we perform all the actions that are necessary for its working and then call the method at the project entry point.

Akili.component('app', this);

The string above registers the component so that we can use it in the template. After that, the route is added to router, etc.

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

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

With this line, we linked store.loader of the store and scope.showLoader of the current component. By default, communication is created in both directions. If store.loader changes, then we get changes to 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 from the template.

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

It is logo and 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 special component into which the template of the corresponding route level is loaded. In this case, this is the second level. That is, any route-heir from app will be loaded 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, we use dot in the name of the route: app.posts. Now posts are inherited from app.

Also, when declaring the route, we specified 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 router.transition and it is available everywhere.

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 took it from the transition:

this.scope.posts = router.transition.path.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 need to add it to the template's scope:

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

This is a template of the posts. It should display the list of posts. But this component is a controller, so we will not do it right here. The list of posts is something universal, we should be able to use it anywhere. Therefore, it is placed in a 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 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. You could use standard on-input, but it's 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 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. Therefore, 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 a separate PostForm component, so you can 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's property change 
* @message {object} save - sent on form save
*/

It is js-doc with some custom tags.

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

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 post attribute and post scope property of the component. So, if we pass this attribute with some value, then we immediately get changes in scope.post. And if you change scope.post in the component, 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 click event:

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

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

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

It is the code from the template of PageEdit controller. We passed the post through post attribute to 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 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 distribute 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. The presence of a caching system. Sending of any data types, without preliminary actions, 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 all. The example site is here, the source code is here.