Skip to content
•20 min read

RealWorld App with Odoo OWL - Part 4 - Refactoring into OWL Hooks

Philippe L'ATTENTION
RealWorld App with OWL (Odoo Web Library) - Part 4 - Refactoring and Odoo hooks

This is the fourth and final part of our tutorial series about creating the RealWorld App (Demo link) and our current implementation in OWL is available here:

Coding-Dodo/owl-realworld-app
OWL RealWorld App Implementation. Contribute to Coding-Dodo/owl-realworld-app development by creating an account on GitHub.

To continue directly where we left off last time, you can clone this branch:

git clone -b feature/displaying-articles https://github.com/Coding-Dodo/owl-realworld-app.git

Or continue with your own project, in this part, we will refactor some redundant code.

  • We will use hooks to make pieces of code reusable via composition into different components.
  • Inside these hooks, we will use built-in OWL hooks like useEnv, useComponent, useGetters, and onWillStart.

Completing missing features from part 3.

In the last chapter we left with some homework to do, the code is already inside the repository you pulled but wasn't explained in the last part.

The ArticleMeta Component

The ArticleMeta Component will contain metadata about an article, the author, likes, and follow button if present. These features are present in the Article listing and on the single Article page so we decide to extract that logic into its own Component.

ArticleMeta Component props

  • This component will take an article props
  • And also a boolean articlesListMode to know if we are on the listing of articles or on the single article because the display will be slightly different in both cases. Notably, the Like button is different and the follow button is not available on the listing.

ArticleMeta State and actions

This Component will handle the three actions buttons:

  • Following the author of the article with state updatingFollowing
  • Favoriting the article with state updatingFavorited
  • Deleting the article if the user is the author with state deletingArticle

Emitting events when the article is favorited or the author followed.

The article is passed as a props so it shouldn't update itself to be favorited so we will fire events when these actions are performed so the parent Component can update its own state.

  async updateFavorited(slug, favorited) {
    if (!this.getters.userLoggedIn()) {
      this.env.router.navigate({ to: "LOG_IN" });
      return;
    }
    let response = {};
    Object.assign(this.state, { updatingFavorited: true });
    if (favorited === true) {
      response = await this.conduitApi.favoriteArticle(slug);
    } else {
      response = await this.conduitApi.unfavoriteArticle(slug);
    }
    Object.assign(this.state, { updatingFavorited: false });
    this.trigger("update-favorited", {
      article: response.article,
    });
  }

  async updateFollowing(username, following) {
    if (!this.getters.userLoggedIn()) {
      this.env.router.navigate({ to: "LOG_IN" });
      return;
    }
    let response = {};
    Object.assign(this.state, { updatingFollowing: true });
    if (following === true) {
      response = await conduitApi.followUser(username);
    } else {
      response = await conduitApi.unfollowUser(username);
    }
    Object.assign(this.state, { updatingFollowing: false });
    this.trigger("update-following", {
      profile: response.profile,
    });
  }

Full Component code

With that knowledge, we will create the component inside ./src/components/ArticleMeta.js :

import { Component, tags, hooks, router, useState } from "@odoo/owl";
const { useGetters } = hooks;
const { Link } = router;
import { useApi } from "../hooks/useApi";

const ARTICLE_META_PAGE_TEMPLATE = tags.xml/* xml */ `
<div class="article-meta">
    <a href=""><img t-att-src="props.article.author.image" /></a>
    <div class="info">
        <a href="#" class="author" t-esc="props.article.author.username"></a>
        <span class="date" t-esc="getArticleDate(props.article.createdAt)"></span>
    </div>
    <!-- Articles List mode with only heart button -->
    <t t-if="props.articlesListMode">
        <button t-attf-class="btn btn-sm pull-xs-right {{ props.article.favorited ? 'btn-primary': 'btn-outline-primary' }}" t-att-disabled="state.updatingFavorited" t-on-click="updateFavorited(props.article.slug, !props.article.favorited)">
            <i class="ion-heart"></i> <t t-esc="props.article.favoritesCount"/>
        </button>
    </t>
    <!-- Article Page mode with following/favorite/edit/delete conditional buttons -->
    <t t-else="">
        <span t-if="userIsAuthor()">
            <Link class="btn btn-outline-secondary btn-sm" to="'EDITOR'">
                <i class="ion-edit"></i> Edit Article
            </Link>
            <button t-attf-class="btn btn-outline-danger btn-sm" t-on-click="deleteArticle(props.article.slug)">
                <i class="ion-trash-a"></i> Delete Article
            </button>
        </span>
        <span t-else="">
            <button 
                t-attf-class="btn btn-sm {{ props.article.author.following ? 'btn-secondary' : 'btn-outline-secondary' }}" 
                t-on-click="updateFavorited(props.article.author.username, !props.article.author.following)"
                t-att-disabled="state.updatingFollowing"
            >
                <i class="ion-plus-round"></i> <t t-esc="props.article.author.following ? 'Unfollow' : 'Follow'"/> <t t-esc="props.article.author.username"/>
            </button> 
            <button 
                t-attf-class="btn btn-sm {{ props.article.favorited ? 'btn-primary': 'btn-outline-primary' }}" 
                t-att-disabled="state.updatingFavorited" 
                t-on-click="updateFavorited(props.article.slug, !props.article.favorited)"
            >
                <i class="ion-heart"></i> <t t-esc="props.article.favorited ? 'Unfavorite': 'Favorite'"/> Post
                <span class="counter">(<t t-esc="props.article.favoritesCount"/>)</span>
            </button>
        </span>
    </t>
</div>
`;
export class ArticleMeta extends Component {
  static template = ARTICLE_META_PAGE_TEMPLATE;
  static components = { Link };
  conduitApi = useApi();
  getters = useGetters();
  state = useState({
    updatingFollowing: false,
    updatingFavorited: false,
    deletingArticle: false,
  });
  static props = {
    article: { type: Object },
    articlesListMode: { type: Boolean, optional: true },
  };

  getArticleDate(date) {
    let articleDate = new Date(date);
    return articleDate.toLocaleDateString("en-US", {
      year: "numeric",
      month: "long",
      day: "numeric",
    });
  }

  async updateFavorited(slug, favorited) {
    if (!this.getters.userLoggedIn()) {
      this.env.router.navigate({ to: "LOG_IN" });
      return;
    }
    let response = {};
    Object.assign(this.state, { updatingFavorited: true });
    if (favorited === true) {
      response = await this.conduitApi.favoriteArticle(slug);
    } else {
      response = await this.conduitApi.unfavoriteArticle(slug);
    }
    Object.assign(this.state, { updatingFavorited: false });
    this.trigger("update-favorited", {
      article: response.article,
    });
  }

  async updateFollowing(username, following) {
    if (!this.getters.userLoggedIn()) {
      this.env.router.navigate({ to: "LOG_IN" });
      return;
    }
    let response = {};
    Object.assign(this.state, { updatingFollowing: true });
    if (following === true) {
      response = await conduitApi.followUser(username);
    } else {
      response = await conduitApi.unfollowUser(username);
    }
    Object.assign(this.state, { updatingFollowing: false });
    this.trigger("update-following", {
      profile: response.profile,
    });
  }
  async deleteArticle(slug) {
    this.conduitApi.deleteArticle(slug);
    this.env.router.navigate({
      to: "PROFILE",
      params: { username: this.getters.getUser().username },
    });
  }

  userIsAuthor() {
    return (
      this.getters.userLoggedIn() &&
      this.getters.getUser().username == this.props.article.author.username
    );
  }
}

Now that we have extracted that logic to this ArticleMeta Component we will modify the ArticlePage.js and Article.js accordingly.

These components will have to listen to the custom events and update their state with the event.detail.

// ....
const ARTICLE_PAGE_TEMPLATE = tags.xml/* xml */ `
<div class="article-page">
  <div class="banner">
    <div class="container">
      <h1 t-esc="state.article.title"></h1>
      <ArticleMeta 
        article="state.article" 
        t-on-update-following="updateFollowing" 
        t-on-update-favorited="updateFavorited"
        updatingFollowing="state.updatingFollowing"
        updatingFavorited="state.updatingFavorited"
        deletingArticle="state.deletingArticle"
      />
    </div>
  </div>
  <div class="container page">
    <div class="row article-content">
      <div class="col-md-12">
        <div t-raw="renderMarkdown(state.article.body)"/>
      </div>
    </div>
    <hr />
    <div class="article-actions">
      <ArticleMeta 
        article="state.article" 
        t-on-update-following="updateFollowing" 
        t-on-update-favorited="updateFavorited"
        updatingFollowing="state.updatingFollowing"
        updatingFavorited="state.updatingFavorited"
        deletingArticle="state.deletingArticle"
      />
    </div>
    <CommentsSection articleSlug="state.article.slug"/>
  </div>
</div>
`;
// ....
export class ArticlePage extends Component {
  static template = ARTICLE_PAGE_TEMPLATE;
  static components = { ArticleMeta, CommentsSection };
  getters = useGetters();
  conduitApi = useApi();
  state = useState({
    article: {
      slug: "",
      title: "",
      description: "",
      body: "",
      tagList: [],
      createdAt: "",
      updatedAt: "",
      favorited: false,
      favoritesCount: 0,
      author: {
        username: "",
        bio: "",
        image: "",
        following: false,
      },
    },
    updatingFollowing: false,
    updatingFavorited: false,
    deletingArticle: false,
  });
  async fetchArticle(slug) {
    let response = await this.conduitApi.getArticle(slug);
    if (response && response.article) {
      Object.assign(this.state, response);
    }
  }
  async willStart() {
    let slug = this.env.router.currentParams.slug;
    await this.fetchArticle(slug);
  }

  renderMarkdown(content) {
    return marked(content);
  }

  onUpdateFollowing(ev) {
    Object.assign(this.article.author, ev.detail.profile);
  }

  onUpdateFavorited(ev) {
    Object.assign(this.article, ev.detail.article);
  }
}
./src/pages/ArticlePage.js

./src/pages/ArticlePage.js

The same pattern applies to the Article Component.

import { Component, tags, router } from "@odoo/owl";
import { ArticleMeta } from "./ArticleMeta";
const Link = router.Link;

const ARTICLE_TEMPLATE = tags.xml/*xml*/ `
<div class="article-preview">
    <ArticleMeta 
        article="props.article"
        articlesListMode="true"
      />
    <Link to="'ARTICLE'" params="{slug: props.article.slug}" class="preview-link">
        <h1><t t-esc="props.article.title"/></h1>
        <p><t t-esc="props.article.description"/></p>
        <span>Read more...</span>
    </Link>
</div>
`;
export class Article extends Component {
  static template = ARTICLE_TEMPLATE;
  static components = { Link, ArticleMeta };
  static props = {
    article: { type: Object },
  };
}
./src/components/Article.js

./src/components/Article.js

Notice that we don't listen to the event here, we will listen to the event on the parent Component ArticlesList that contains the state:

const ARTICLESLIST_TEMPLATE = tags.xml/*xml*/ `
<section>
    <t t-foreach="state.articles" t-as="article" t-key="article.slug">
        <ArticlePreview article="article" t-on-update-favorited="onUpdateFavorited(article)"/>
    </t>
    <span class="loading-articles" t-if="state.loading">
        Loading Articles...
    </span>
    <Pagination 
        t-if="! state.loading"
        itemsPerPage="props.queryOptions.limit" 
        totalCount="state.articlesCount"
        currentOffset="props.queryOptions.offset"
    />
</se
export class ArticlesList extends Component {
  static template = ARTICLESLIST_TEMPLATE;
  // ...
  onUpdateFavorited(article, ev) {
    Object.assign(article, ev.detail.article);
  }
}
./src/components/ArticlesList.js

./src/components/ArticlesList.js

Since events bubble up the Component chain we can listen to them much higher in the Component tree order.

Making the Editor Page Dynamic (Edit existing article) with route parameters.

Inside the application, the Editor page is used to create a new article but also to edit an existing one. So depending on if a slug is present inside the route we should redirect to a blank new article or load an existing article with its data inside each of the fields.

We can already imagine that the logic to get a single article via a slug will be similar to what we did with the regular Article Page Component. It will involve making an API request inside the willStart function to get the article and load it into the state. Later, we will see how to avoid code duplication with that piece of functionality, but for now, let's begin with the routing.

Editing the routes.

Inside ./src/main.js we will modify the routes

export const ROUTES = [
  { name: "HOME", path: "/", component: Home },
  { name: "LOG_IN", path: "/login", component: LogIn },
  { name: "REGISTER", path: "/register", component: Register },
  {
    name: "SETTINGS",
    path: "/settings",
    component: Settings,
    beforeRouteEnter: authRoute,
  },
  {
    name: "EDITOR",
    path: "/editor",
    component: Editor,
    beforeRouteEnter: authRoute,
  },
  {
    name: "EDITOR_ARTICLE",
    path: "/editor/{{slug}}",
    component: Editor,
    beforeRouteEnter: authRoute,
  },
  // ... rest of the routes

We created a new route that uses the same component <Editor/>, but with a slug parameter as the specs tell us to do.

Similar to the ArticlePage (also loaded via a slug), we will have to fetch the Article in the willStart and assign it to the state of the Component. To avoid duplication we will use the OWL hooks system.

Refactoring the loading of one article with the onWillStart hook.

When you have to load the same kind of data, in multiple different Components, you can use hooks to avoid code duplication. The willStart function of a Component can be also written as a hook with onWillStart. All the logic written inside these two functions are "merged" with each other, they are not mutually exclusive, meaning that you can use a willStart on a Component and also injecting onWillStart hooks and all of them will trigger.

Inside the ArticlePage component we will refactor this part of the code:

async fetchArticle(slug) {
    let response = await this.conduitApi.getArticle(slug);
    if (
      response &&
      response.article &&
      response.article.author.username == this.getters.getUser().username
    ) {
      Object.assign(this.state, response);
    }
  }
  async willStart() {
    if (this.env.router.currentParams && this.env.router.currentParams.slug) {
      let slug = this.env.router.currentParams.slug;
      await this.fetchArticle(slug);
    }
  }

Refactoring into a hook consists of writing a JavaScript closure. Closures are an integral part of what makes JavaScript awesome and are a great way to achieve Composition like in other Object Oriented Programmation languages.

I will not explain JavaScript closure in-depth here, because I would never do a better job than what already exists in the amazing You don't Know JS book. I invite you to read everything but:

Closures are useful because they let you associate data (the lexical environment) with a function that operates on that data. In our case, the article in the state will be associated with the closure (saved inside the Closure) and also the functions responsible for fetching the data.

We will create a loader that will do the same thing and return the article. Inside our hooks folder, we create a new file called useArticleLoader.js.

import { useState, hooks } from "@odoo/owl";
const { onWillStart, useEnv } = hooks;
import { useApi } from "../hooks/useApi";

export function useArticleLoader() {
  const conduitApi = useApi();
  const article = useState({
    slug: "",
    title: "",
    description: "",
    body: "",
    tagList: [],
    createdAt: "",
    updatedAt: "",
    favorited: false,
    favoritesCount: 0,
    author: {
      username: "",
      bio: "",
      image: "",
      following: false,
    },
  });
  const env = useEnv();
  async function fetchArticle(slug) {
    let response = await conduitApi.getArticle(slug);
    if (response && response.article) {
      Object.assign(article, response.article);
    }
  }
  onWillStart(async () => {
    let slug = env.router.currentParams.slug;
    await fetchArticle(slug);
  });
  return article;
}
./src/hooks/useArticleLoader.js

./src/hooks/useArticleLoader.js

From within our useArticleLoader we can use different OWL hooks like useEnv to get access to the current env (and the router) or useState to assign state data and also our own useApi to call API. The logic stays mostly the same as what it was inside the original Component.

onWillStart is an asynchronous hook and will be run just before the component is first rendered, exactly like willStart. Inside this function, we have access to the env and we decide to directly expect that the slug is inside the route current parameters. This is not optimal behavior because we make our hook dependant on the fact that the Article is loaded via slug (and this is always the case in our application). It would be better to separate concerns and having another function get the slug to leave our hook doing is only purpose: fetching an article. But this will do for now as we don't want to overengineer too much.

Note: onWillUpdateProps is not used in our example but you have to know that the hook exists. It is also an asynchronous hook that will trigger the associated function every time the props of the Component are updated, exactly like willUpdateProps.

Using our newly created useArticleLoader hook

We will now refactor the ArticlePage Component to make use of this new hook, open the ArticlePage.js Component:

import { useArticleLoader } from "../hooks/useArticleLoader";
// other Imports
// ...
const ARTICLE_PAGE_TEMPLATE = tags.xml/* xml */ `
<div class="article-page">
  <div class="banner">
    <div class="container">
      <h1 t-esc="article.title"></h1>
      <ArticleMeta 
        article="article" 
        t-on-update-following="updateFollowing" 
        t-on-update-favorited="updateFavorited"
        t-on-delete-article="deleteArticle"
        updatingFollowing="state.updatingFollowing"
        updatingFavorited="state.updatingFavorited"
        deletingArticle="state.deletingArticle"
      />
    </div>
  </div>
  <div class="container page">
    <div class="row article-content">
      <div class="col-md-12">
        <div t-raw="renderMarkdown(article.body)"/>
      </div>
    </div>
    <hr />

    <div class="article-actions">
      <ArticleMeta 
        article="article" 
        t-on-update-following="updateFollowing" 
        t-on-update-favorited="updateFavorited"
        t-on-delete-article="deleteArticle"
        updatingFollowing="state.updatingFollowing"
        updatingFavorited="state.updatingFavorited"
        deletingArticle="state.deletingArticle"
      />
    </div>

    <CommentsSection articleSlug="article.slug"/>
  </div>
</div>
`;
export class ArticlePage extends Component {
  static template = ARTICLE_PAGE_TEMPLATE;
  static components = { ArticleMeta, CommentsSection };
  getters = useGetters();
  conduitApi = useApi();
  state = useState({
    updatingFollowing: false,
    updatingFavorited: false,
    deletingArticle: false,
  });
  article = useArticleLoader();

  renderMarkdown(content) {
    return marked(content);
  }
// ...
}
./src/pages/ArticlePage.js

./src/pages/ArticlePage.js

As you can see, we kinda have 2 states but in reality both of them useState so state and article share the reactivity (both of them being a ProxyObject). Modifying one or the other will trigger the "state changed" hook observed!

Make sure that everywhere state.article was used it is now replaced with article.

Improving the Editor with the new hook

import { Component, tags, useState, hooks } from "@odoo/owl";
const { useGetters } = hooks;
import { useApi } from "../hooks/useApi";
import { useArticleLoader } from "../hooks/useArticleLoader";
const { xml } = tags;

const EDITOR_TEMPLATE = xml/* xml */ `
<div class="editor-page">
  <div class="container page">
    <div class="row">

      <div class="col-md-10 offset-md-1 col-xs-12">
        <ul class="error-messages">
            <li t-foreach="state.errors" t-as="errorKey">
                <t t-esc="errorKey"/> <t t-esc="state.errors[errorKey]"/> 
            </li>
        </ul>
        <form>
          <fieldset>
            <fieldset class="form-group">
                <input type="text" class="form-control form-control-lg" placeholder="Article Title" t-model="article.title" t-att-disabled="state.publishingArticle"/>
            </fieldset>
            <fieldset class="form-group">
                <input type="text" class="form-control" placeholder="What's this article about?" t-model="article.description" t-att-disabled="state.publishingArticle"/>
            </fieldset>
            <fieldset class="form-group">
                <textarea class="form-control" rows="8" placeholder="Write your article (in markdown)" t-model="article.body" t-att-disabled="state.publishingArticle"></textarea>
            </fieldset>
            <fieldset class="form-group">
                <input type="text" class="form-control" placeholder="Enter tags" t-att-disabled="state.publishingArticle"/><div class="tag-list"></div>
            </fieldset>
            <button class="btn btn-lg pull-xs-right btn-primary" type="button" t-att-disabled="state.publishingArticle" t-on-click.prevent="publishArticle">
                Publish Article
            </button>
          </fieldset>
        </form>
      </div>

    </div>
  </div>
</div>
`;
export class Editor extends Component {
  static template = EDITOR_TEMPLATE;
  getters = useGetters();
  state = useState({
    publishingArticle: false,
    errors: {},
  });
  article = useArticleLoader();
  conduitApi = useApi();

  async publishArticle() {
    let response = {};
    Object.assign(this.state, { publishingArticle: true });
    if (this.article.slug) {
      response = await this.conduitApi.updateArticle(
        this.article.slug,
        this.article
      );
    } else {
      response = await this.conduitApi.createArticle(this.article);
    }
    Object.assign(this.state, { publishingArticle: false });
    if (response.article) {
      this.env.router.navigate({
        to: "PROFILE",
        params: { username: this.getters.getUser().username },
      });
    } else {
      Object.assign(this.state.errors, response.errors);
    }
  }
}

Modifying the publishArticle function.

Since the Editor Component will now create and update an article we use a conditional to check if the slug is present in our article state and act consequently.

We also improved the Editor page with error handling coming from the API the same way we did in the first part of this series with the LogIn and Register Component.

Refactoring the ArticleMeta Component with reusable pieces of action logic as hooks.

The ArticleMeta and the Profile (author) Page share the functionality of updating the "following" attribute of the "profile" object representing the fact that the currently logged-in user follows the author or not.

This is a good enough reason for us to refactor this piece of logic into a hook. This hook will be responsible for calling the API and handle the intermediate loading state when we make the API call. This hook will need to be aware of the user authenticated state and also have some way to access Component internal to trigger/dispatch events from the Component that will use this hook.

Dispatching Component events from a hook with useComponent.

To have access to the Component functions like trigger OWL gives us access to an internal hook called useComponent, this is a basic example:

import { hooks } from "@odoo/owl";
const { useComponent } = hooks;

export function useMyCustomAction() {
  const comp = useComponent();
  const doSomething = async (input) => {
    // do something
    let data = input + "test" ;
    //triggerring event of Component
	comp.trigger("my-event", {
      example: data,
    });
    return data;
  };
  return { doSomething };
}

As we can see in this example hook we declared const comp = useComponent(); and then use its trigger function to dispatch an event.

The Component that will end up using this custom action will trigger a the "my-event" when it will use the doSomething function.

Coding the real useProfileActions hook

import { useState, hooks } from "@odoo/owl";
const { useEnv, useGetters, useComponent } = hooks;
import { useApi } from "../hooks/useApi";

export function useProfileActions() {
  const conduitApi = useApi();
  const env = useEnv();
  const getters = useGetters();
  const comp = useComponent();
  const state = useState({
    updatingFollowing: false
  });
  const updateFollowing = async (username, following) => {
    if (!getters.userLoggedIn()) {
      env.router.navigate({ to: "LOG_IN" });
      return;
    }
    let response = {};
    Object.assign(state, { updatingFollowing: true });
    if (following === true) {
      response = await conduitApi.followUser(username);
    } else {
      response = await conduitApi.unfollowUser(username);
    }
    Object.assign(state, { updatingFollowing: false });
    comp.trigger("update-following", {
      profile: response.profile,
    });
    return response.profile;
  };
  return { state, updateFollowing };
}
./src/hooks/useProfileActions.js

./src/hooks/useProfileActions.js

First, this component makes use of all the hooks we saw before because it needs to have access to the Store actions with useGetters and test if the user is logged in. If not it will need the env to use the router and redirect the user to the login page. Then it needs our custom API hook and useComponent hook to trigger an event.

As we can see here, this hook returns multiple values return { state, updateFollowing };.

Similar to the last hook we created we have an inner state that we will be able to use in our final component. This state contains the loading boolean of the following user API call.

But in this case, we also return the function itself so that the Component that will use these actions also has access to it.

Coding the use articleActions hook

The article actions hook will be very similar to the useProfileAction hook but with one more function

import { useState, hooks } from "@odoo/owl";
const { useEnv, useGetters, useComponent } = hooks;
import { useApi } from "../hooks/useApi";

export function useArticleActions() {
  const conduitApi = useApi();
  const env = useEnv();
  const getters = useGetters();
  const comp = useComponent();
  const state = useState({
    updatingFavorited: false,
    deltingArticle: false,
  });
  const updateFavorited = async (slug, favorited) => {
    if (!getters.userLoggedIn()) {
      env.router.navigate({ to: "LOG_IN" });
      return;
    }
    let response = {};
    Object.assign(state, { updatingFavorited: true });
    if (favorited === true) {
      response = await conduitApi.favoriteArticle(slug);
    } else {
      response = await conduitApi.unfavoriteArticle(slug);
    }
    Object.assign(state, { updatingFavorited: false });
    comp.trigger("update-favorited", {
      article: response.article,
    });
    return response.article;
  };
  const deleteArticle = async (slug) => {
    conduitApi.deleteArticle(slug);
    env.router.navigate({
      to: "PROFILE",
      params: { username: getters.getUser().username },
    });
  };

  return { state, updateFavorited, deleteArticle };
}
./src/hooks/useArticleActions.js

./src/hooks/useArticleActions.js

This is pretty similar to the last hook we created but there is one more function that we return. This hook will handle the favorited boolean of an article and will also contain the logic associated to delete an article since the button is present in the ArticleMeta Component.

Using our action hooks inside ArticleMeta Component

To have access to the state and the function of our hook we will call them inside the constructor of our Component:

constructor(...args) {
  super(...args);
  Object.assign(this, { ...useArticleActions(), ...useProfileActions()});
  }

We use the spread syntax ... inside our constructor and assign it to this. So now, our component internally have

  • all the state of the 2 hooks with the attributes updatingFavorited, deletingArticle, and updatingFollowing
  • The component also now have access to 3 new functions updateFollowing, updateFavorited and deleteArticle.

So we can now refactor our ArticleMeta Component like that

import { Component, tags, hooks, router } from "@odoo/owl";
const { useGetters } = hooks;
const { Link } = router;
import { useArticleActions } from "../hooks/useArticleActions";
import { useProfileActions } from "../hooks/useProfileActions";
import { formatDate } from "../utilities/formatdate";

const ARTICLE_META_PAGE_TEMPLATE = tags.xml/* xml */ `
<div class="article-meta">
    <a href=""><img t-att-src="props.article.author.image" /></a>
    <div class="info">
        <Link to="'PROFILE'" params="{username: props.article.author.username}" class="author" t-esc="props.article.author.username"></Link>
        <span class="date" t-esc="formatDate(props.article.createdAt)"></span>
    </div>
    <!-- Articles List mode with only heart button -->
    <t t-if="props.articlesListMode">
        <button t-attf-class="btn btn-sm pull-xs-right {{ props.article.favorited ? 'btn-primary': 'btn-outline-primary' }}" t-att-disabled="state.updatingFavorited" t-on-click="updateFavorited(props.article.slug, !props.article.favorited)">
            <i class="ion-heart"></i> <t t-esc="props.article.favoritesCount"/>
        </button>
    </t>
    <!-- Article Page mode with following/favorite/edit/delete conditional buttons -->
    <t t-else="">
        <span t-if="userIsAuthor()">
            <Link class="btn btn-outline-secondary btn-sm" to="'EDITOR_ARTICLE'" params="{ slug: props.article.slug }">
                <i class="ion-edit"></i> Edit Article
            </Link>

            <button t-attf-class="btn btn-outline-danger btn-sm" t-on-click.prevent="deleteArticle(props.article.slug)">
                <i class="ion-trash-a"></i> Delete Article
            </button>
        </span>
        <span t-else="">
            <button 
                t-attf-class="btn btn-sm {{ props.article.author.following ? 'btn-secondary' : 'btn-outline-secondary' }}" 
                t-on-click="updateFollowing(props.article.author.username, !props.article.author.following)" 
                t-att-disabled="state.updatingFollowing"
            >
                <i class="ion-plus-round"></i> <t t-esc="props.article.author.following ? 'Unfollow' : 'Follow'"/> <t t-esc="props.article.author.username"/>
            </button> 
            <button 
                t-attf-class="btn btn-sm {{ props.article.favorited ? 'btn-primary': 'btn-outline-primary' }}" 
                t-att-disabled="state.updatingFavorited" 
                t-on-click="updateFavorited(props.article.slug, !props.article.favorited)"
            >
                <i class="ion-heart"></i> <t t-esc="props.article.favorited ? 'Unfavorite': 'Favorite'"/> Post
                <span class="counter">(<t t-esc="props.article.favoritesCount"/>)</span>
            </button>
        </span>
    </t>
</div>
`;
export class ArticleMeta extends Component {
  static template = ARTICLE_META_PAGE_TEMPLATE;
  static components = { Link };
  static props = {
    article: { type: Object },
    articlesListMode: { type: Boolean, optional: true },
  };
  getters = useGetters();
  formatDate = formatDate;

  constructor(...args) {
    super(...args);
    Object.assign(this, { ...useArticleActions(), ...useProfileActions() });
  }

  userIsAuthor() {
    return (
      this.getters.userLoggedIn() &&
      this.getters.getUser().username == this.props.article.author.username
    );
  }
}
./src/components/ArticleMeta.js

./src/components/ArticleMeta.js

You can notice inside the template that we can do stuff like t-att-disabled="state.updatingFavorited" even if we did not explicitly declared state. This is because we used the spread operator in the constructor and assigned it to this and state is declared inside each of the hooks. This is reducing drastically the logic inside our Component and transforming it into a representation Component not coupled too much with business logic.

You may also notice that we are importing the formatDate function with

import { formatDate } from "../utilities/formatdate";

We used this opportunity to export the formatDate function into a single file since it was used inside many different Components. The source code for this file is simply the same function exported:

import "regenerator-runtime/runtime";

export function formatDate(string) {
  let dateObject = new Date(string);
  return dateObject.toLocaleDateString("en-US", {
    year: "numeric",
    month: "long",
    day: "numeric",
  });
}
./src/utilities/formatdate.js

./src/utilities/formatdate.js

Now that our ArticleMeta component is refactored we will tackle the Profile page that also uses the functionality of "following an author".

Updating the Profile page Component and using our hook.

Before that, our Profile page Component was just pure static XML, so we will make it dynamic first by creating a dynamic route inside our main.js file

Updating the routes

export const ROUTES = [
  //...
  {
    name: "PROFILE",
    path: "/profile/@{{username}}",
    component: Profile,
  },
  {
    name: "ARTICLE",
    path: "/article/{{slug}}",
    component: ArticlePage,
  },
];

Don't forget to modify all the LINK Components toward that new route, especially the one where the current user is clicking on his own avatar, inside Navbar.js

<li class="nav-item" t-if="getters.userLoggedIn()">
    <NavbarLink to="'PROFILE'" params="{ username: getters.getUser().username }" class="nav-link"><t t-esc="getters.getUser().username"/></NavbarLink>
</li>

Creating the Component

The Profile Page behaves similarly to the Homepage by displaying a list of articles inside 2 tabs:

  • The first tab (Navigation mode) is the Author articles
  • The second navigation mode is the articles that the author favorited

The logic of navigation mode change will be copy-pasted from what is inside the Homepage, it may be a good opportunity for refactoring but I will let you try by yourself, but be careful to not overengineer too much. Sometimes 2 functionalities may look similar and trigger the need to refactor but you can lose a lot of readability in the process and add artificial complexity.

This is the code of the Component:

import { Component, tags, useState, hooks, router, utils } from "@odoo/owl";
const { debounce } = utils;
const { useGetters } = hooks;
const { Link } = router;
import { useApi } from "../hooks/useApi";
import { ArticlesList } from "../components/ArticlesList";
import { useProfileActions } from "../hooks/useProfileActions";
const { xml } = tags;

const PROFILE_TEMPLATE = xml/* xml */ `
<div class="profile-page">
    <div class="user-info">
        <div class="container">
            <div class="row">

            <div class="col-xs-12 col-md-10 offset-md-1">
                <img t-att-src="state.profile.image" class="user-img" />
                <h4 t-esc="state.profile.username"></h4>
                <p t-esc="state.profile.bio"></p>
                <t t-if="state.profile.username == getters.getUser().username">
                    <Link to="'SETTINGS'" class="btn btn-sm btn-outline-secondary action-btn">
                        <i class="ion-gear-a"></i> Edit Profile Settings
                    </Link>
                </t>
                <t t-else="">
                    <button 
                        class="btn btn-sm btn-outline-secondary action-btn" 
                        t-attf-class="btn btn-sm action-btn {{ state.profile.following ? 'btn-secondary' : 'btn-outline-secondary' }}" 
                        t-att-disabled="state.updatingFollowing"
                        t-on-click.prevent="actionUpdateFollowing"
                    >
                        <i class="ion-plus-round"></i> <t t-esc="state.profile.following ? 'Unfollow' : 'Follow'"/> <t t-esc="state.profile.username"/>
                    </button>
                </t>
            </div>

            </div>
        </div>
    </div>

    <div class="container">
    <div class="row">

        <div class="col-xs-12 col-md-10 offset-md-1">
            <div class="articles-toggle">
                <ul class="nav nav-pills outline-active">
                <li class="nav-item">
                    <a t-attf-class="nav-link {{ state.navigationMode == 'AUTHOR' ? 'active' : '' }}"
                        href="" 
                        t-on-click.prevent="changeNavigationMode('AUTHOR')">
                    My Articles
                    </a>
                </li>
                <li class="nav-item">
                    <a  t-attf-class="nav-link {{ state.navigationMode == 'FAVORITED' ? 'active' : '' }}"
                        href="" 
                        t-on-click.prevent="changeNavigationMode('FAVORITED')">
                        Favorited Articles
                    </a>
                </li>
                </ul>
            </div>
            <ArticlesList queryOptions="state.articlesOptions" t-on-update-offset="updateOffset"/>
        </div>
    </div>
    </div>
</div>
`;

export class Profile extends Component {
  static template = PROFILE_TEMPLATE;
  static components = { ArticlesList, Link };
  conduitApi = useApi();
  getters = useGetters();

  constructor(...args) {
    super(...args);
    this.state = useState({
      profile: {},
      navigationMode: "",
      articlesOptions: {},
    });
    Object.assign(this, { ...useProfileActions() });
  }

  async fetchProfile(username) {
    let response = await this.conduitApi.getProfile(username);
    if (response && response.profile) {
      Object.assign(this.state, {
        ...response,
        navigationMode: "AUTHOR",
        articlesOptions: {
          author: response.profile.username,
          limit: 10,
          offset: 0,
        },
      });
    }
  }

  async actionUpdateFollowing() {
    let profile = await this.updateFollowing(
      this.state.profile.username,
      !this.state.profile.following
    );
    Object.assign(this.state.profile, profile);
  }

  async willUpdateProps() {
    let username = this.env.router.currentParams.username;
    await this.fetchProfile(username);
  }

  async willStart() {
    let username = this.env.router.currentParams.username;
    await this.fetchProfile(username);
  }

  changeNavigationMode(navigationMode) {
    let articlesOptions = {};
    switch (navigationMode) {
      case "AUTHOR":
        articlesOptions = {
          author: this.state.profile.username,
          limit: 10,
          offset: 0,
        };
        break;
      case "FAVORITED":
        articlesOptions = {
          favorited: this.state.profile.username,
          limit: 10,
          offset: 0,
        };
        break;
      default:
        articlesOptions = {
          author: this.state.profile.username,
          limit: 10,
          offset: 0,
        };
    }
    Object.assign(this.state, {
      navigationMode: navigationMode,
      articlesOptions: articlesOptions,
    });
  }
  updateOffset(ev) {
    let newArticlesOptions = {
      ...this.state.articlesOptions,
      offset: ev.detail.offset,
    };
    Object.assign(this.state, { articlesOptions: newArticlesOptions });
  }
}

Most of the Component logic will seem familiar by now, the piece of code that may be a bit weird is this one:

async actionUpdateFollowing() {
    let profile = await this.updateFollowing(
      this.state.profile.username,
      !this.state.profile.following
    );
    Object.assign(this.state.profile, profile);
  }

This function is the function called by clicking on the button. And this function calls the one inside the hook called updateFollowing to then assign the new following state to the current profile loaded.

Closing thoughts and follow up

In this tutorial we learned:

  • OWL hooks, onWillStart, useComponent, useEnv.
  • Common refactoring into custom hooks.

Thanks for following, I hope it was helpful to you the goal was to have a real-world application done from scratch.

As I'm finishing writing this Tutorial, the Odoo team just merged the complete rework of the Odoo WebClient inside the main (future v15) branch of Odoo.

[MERGE] *: rewrite the webclient in OWL (phase 1) by aab-odoo · Pull Request #58287 · odoo/odoo
This commit is the first phase of the conversion of the web/ JS codebase to the owl framework. The impact of this commit is two-fold. First, it rewrites the framework part of web with a new system ...

With what we build and this Merge in Odoo you will be able to have, I think, a better understanding of how they achieved it (Odoo WebClient is much more complicated).

Anyway, the source code for this completed tutorial is available here, you will see more refactoring and polishing by reading the code, but feel free to continue by yourself polishing, refactoring, and writing tests ?.

git clone https://github.com/Coding-Dodo/owl-realworld-app.git

Part 1 of the series, Basic routing navigation and Components creation

Part 2 of the series, Authentication, OWL Store

Part 3 of the series, Dynamic Routing, and willStart functions.

Bonus

If like me you like to work with TailwindCSS, I've created an OWL + TailwindCSS starter template here:

Coding-Dodo/OWL-JavaScript-Tailwind-Project-Starter
OWL JavaScript Project Starter with Tailwind and postcss - Coding-Dodo/OWL-JavaScript-Tailwind-Project-Starter

It comes with 2 example Components

  • An Autocomplete Search Component stylized with Tailwind
  • A basic DropDown Component stylized with Tailwind
Tailwind OWL Starter Template
Tailwind OWL Starter Template

Have fun!

Part 1 - Basic Structure of the OWL Project and Components

Part 2 - Authentication, OWL Store

Part 3 - Dynamic Routing, and willStart functions.

Part 4 (this one) - Refactoring, and Hooks.

Buy Me A Coffee

You might also like