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

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

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

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

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

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.

Read the full article

Sign up now to read the full article and get access to all members-only posts.

Subscribe
Already have an account? Sign in
Great! You’ve successfully signed up.
Welcome back! You've successfully signed in.
You've successfully subscribed to Coding Dodo - Odoo, Python & JavaScript Tutorials.
Your link has expired.
Success! Check your email for magic link to sign-in.
Success! Your billing info has been updated.
Your billing was not updated.