Growing Your Own WebApp with Phoenix And Vue
Growing Your Own WebApp with Phoenix And Vue
Joseph Varghese
Buy on Leanpub

Table of Contents

What are we going to build

This book walks you through building a web application for managing a shop. The backend will be written using the Phoenix framework and the frontend will be built using VueJS. We will alternate between writing the backend and the frontend as we progress. We will also talk about the the deployment process.

The source code for client is available here and for server it is available here. Each chapter will be a single commit in the repository, so that it can be easily checked out and followed along as we progress.

Rough Overview

Before starting programming, we should make a rough architecture of our project.

The backend will communicate with the frontend using a REST API. The functionality offered by the project will be as follows.

  • Support for inventory management which includes keeping track of products, the stocks, the taxes and more.
  • Support for billing where a customer can buy multiple products.
  • Transportation service for goods bought by the customer.
  • More requirements in the future.

Database Structure modeled using Typescript Interfaces

Lets simply model these requirements into database tables along with the Typescript interfaces. This provides us with a structure/model, which can be used to communicate between frontend and backend. It also makes our models standard and easier to understand. Soon we will also learn how to automatically generate such an interface from the database tables from phoenix framework. Here are typescript interfaces for the models we will start with.

 1 // Holds all information about a product. The field names are self explanatory.
 2 export interface Product {
 3   updated_at: Date;
 4   tax: number;
 5   stock: number;
 6   price: number;
 7   name: string;
 8   inserted_at: Date;
 9   id: number;
10   details: object;
11   brand_id: number;
12 }
13 
14 // All products belongs to a brand. Just like some shoes belongs to brand Nike.
15 // We store information for Nike here.
16 export interface Brand {
17   updated_at: Date;
18   name: string;
19   inserted_at: Date;
20   id: number;
21   details: object;
22 }
23 
24 // OrderItem is a single item in an order.
25 export interface OrderItem {
26   updated_at: Date;
27   unitPrice: number;
28   product_id: number;
29   order_id: number;
30   inserted_at: Date;
31   id: number;
32   amount: number;
33 }
34 
35 // An order is a collection of order items.
36 // It also includes a reference to customer with customer_id.
37 // Ideally it would make sense to put a customer instance here.
38 // But this makes it easier to interact with the backend.
39 // All fields which ends with an _id is a reference to name before it.
40 // For example customer_id inside Order is a reference to customer table/interface.
41 // The details field in all models are used to accommodate additional information
42 // which is not very relevant/subject to change.
43 export interface Order {
44   updated_at: Date;
45   message: string;
46   inserted_at: Date;
47   id: number;
48   details: object;
49   customer_id: number;
50   creationDate: Date;
51 }
52 
53 // Used to represent transportation of goods to the customer.
54 // It has a reference to the order and also the customer.
55 // Note that we have an address even though have a reference to customer
56 // from which address can be obtained. We have a different address because it
57 // can be the case that shipping address can be different to customer address.
58 export interface Delivery {
59   updated_at: Date;
60   order_id: number;
61   inserted_at: Date;
62   id: number;
63   fare: number;
64   details: object;
65   customer_id: number;
66   address: object;
67 }
68 
69 // Used to represent a customer, who places the order.
70 export interface Customer {
71   updated_at: Date;
72   pincode: string;
73   phone: string;
74   name: string;
75   inserted_at: Date;
76   id: number;
77   details: object;
78 }

In the next chapter, we will see how setup the phoenix framework for our backend and model the database tables. It will have the same structure as the typescript interfaces we just designed.

Setting up Your Phoenix Application

Setting up Phoenix with PostgreSQL

Phoenix docs provides the complete tutorial to install elixir and phoenix framework. Go ahead and install it, I will wait. Once we have installed it, lets start a new phoenix project. Mix is the build tool that ships with Elixir. It provides tasks for performing various operations. Lets use mix to create a new project. We will name our project ms(Yeah why not :D).

1 mix phx.new ms --no-html --no-webpack

This will walk you through creating the project and installing the dependencies. Once it is finished, we will get back the prompt. Now lets import the generated project in to our IDE. I use the Intellij IDEA along with intellij-elixir plugin. You can also use other editors. Import the project via File > Open > path to ms folder, where the mix.exs file is present. Once the project is imported, we look at the database connectivity. In order to know our database credentials we go ms/config/dev.exs.

We need to setup our PostgreSQL with these credentials. Lets set it up using docker and PostgreSQL. Go ahead and install docker from here. Now we pull the PostgreSQL container and run it using the below command.

1 docker run --rm --name md -p 5433:5432 -i -t \
2 -v /home/name/your-directory:/var/lib/postgresql/data \
3 -e POSTGRES_DB=ms_dev -e POSTGRES_PASSWORD=postgres \
4 -e POSTGRES_USER=postgres postgres:latest

As we see we pass the PostgreSQL username, password and database as environment variable to docker using -e flag. Now we need to create the ms_dev database and run the ecto migrations. For this, run the following commands.

1 cd ms
2 mix ecto.create # For creating database
3 mix ecto.migrate # To run migrations, we don't have any now though.
4 mix phx.server # Start the server

Now your phoenix app is reachable at localhost:4000. At this point onwards, I expect a basic understanding of Elixir. For a quick overview, please read elixir in Y minutes. Other Phoenix / Elixir concepts will be explained as we progress. In the next chapter we will model and create our database tables using ecto.

Setting up a PostgreSQL backed HTTP server

Generate tables using Ecto

It is time to make our models and tables using Ecto. Ecto is the library phoenix offers for working with databases. It includes a DSL in which we will be writing our queries. Ecto queries are composable and prevent SQL injections. Lets start with the Products table. If you are unsure how the table structure should look like, refer previous chapters. The phx.gen.json task generates the migrations, tables/models, the view and the controllers. The views generated will be emit JSON. Don’t worry, we will discuss it all :D.

1 mix phx.gen.json Inventory Product products \
2 price:float stock:integer name:string tax:float

Here the Inventory is called as the context module. The phoenix docs explains context as follows. “The context is an Elixir module that serves as an API boundary for the given resource”. In simpler terms, it is like a place, where we put things which belong together. For example, OrderManagement deals with orders. An order can consist of multiple order items. If we think, these two should be considered together/offers a functionality together, we put them in the same context, say OrderManagement. Having a context is beneficial due to several reasons. One reason is that we establish an API boundary. If some module needs to talk with our module, it should use the API. This keeps our modules self-contained and prevents unnecessary coupling with other modules. See this link for more info about bounded contexts.

The Product here is our model, with all the fields for data. products will be name of the database table and is normally the plural form of Product. The others price:float stock:integer name:string tax:float are the fields/columns in the model/database table with their types. Lets see what files are created by this task.

In the lib/ms/inventory we see a new product.ex file.

 1 # defmodule makes a Module in Elixir. Modules are like packages in other
 2 # languages like Java.
 3 defmodule Ms.Inventory.Product do
 4   # use is the keyword used for making use of macros.
 5   # It also executes __using__ function in the Ecto.Schema module.
 6   # It then injects code to our module at compile time.
 7   use Ecto.Schema
 8   # import is like import in other languages like Java/Python.
 9   # It import the Ecto.Changeset into our module so we can use changeset() function
10   import Ecto.Changeset
11 
12   schema "products" do
13     field :name, :string
14     field :price, :float
15     field :stock, :integer
16     field :tax, :float
17     field :details, :map
18     belongs_to(:brand, Ms.Inventory.Brand)
19 
20     # Inserts updated and created timestamp fields to the database
21     timestamps()
22   end
23 
24    # Functions in Elixir and declared using def.
25    @doc false
26   def changeset(product, attrs) do
27     product
28     # cast is like telling we needs these fields from the attrs.
29     # [cast/4](https://hexdocs.pm/ecto/Ecto.Changeset.html#cast/4)
30     |> cast(attrs, [:price, :stock, :name, :tax])
31     |> validate_required([:price, :stock, :name, :tax])
32     # validate is for making sure these values exist before we create a product.
33     # We can also add more requirements that these values should satisfy.
34   end
35 end

Ecto Schema

Schema is like a structure of the Project in database. This is similar with the typescript interface we defined earlier. changeset is a function, which tranforms the incoming data to the model structure, before being stored into the database. We can add validation for data or other functionality inside the changesets. Please read the comments in the above to code to learn more.

Now we will look at the migration file in the priv/repo/migrations/_create_products.exs.

 1 defmodule Ms.Repo.Migrations.CreateProducts do
 2   use Ecto.Migration
 3 
 4   def change do
 5     create table(:products) do
 6       add :price, :float
 7       add :stock, :integer
 8       add :name, :string
 9       add :tax, :string
10 
11       timestamps()
12     end
13 
14   end
15 end

As you can see it has a one to one correspondence with the schema in product.ex file. The add :price, :float is a way of telling that we need to add a field named price with a type float. The change function in a migration file will be called when we do the mix ecto.migrate. Since they are ran sequentially based on the name of file with date in it, we should we careful when editing the migration files manually. Views are formally rendering the schema/model we generated. Since we used mix phx.gen.JSON our generated views produce JSON output. Phoenix also ships with other tasks, which will generate HTML for us. Lets take a look at the views file generated at ms_web/views/product_view.ex

 1 defmodule MsWeb.ProductView do
 2   use MsWeb, :view
 3   alias MsWeb.ProductView
 4 
 5   # Elixir have a very good support for pattern matching.
 6   # In this case the calls to render function, from controller will be
 7   # automatically picked based on the arguments. Also the order in which the
 8   # functions are defined is important, as they are searched in top to down order.
 9   def render("index.json", %{products: products}) do
10     # render_many will render multiple products based on product.json,
11     # which is the last defined render function in this file.
12     %{data: render_many(products, ProductView, "product.json")}
13   end
14 
15   def render("show.json", %{product: product}) do
16     # This function will render a single product using the last render function.
17     %{data: render_one(product, ProductView, "product.json")}
18   end
19 
20   # This is our actual function, which does all the rendering.
21   # Other functions call this function. It simply generates a JSON file with
22   # the fields defined below.
23   # We will see exactly how it looks, when we use it in the frontend.
24   def render("product.json", %{product: product}) do
25     %{
26       id: product.id,
27       price: product.price,
28       stock: product.stock,
29       name: product.name,
30       tax: product.tax,
31     }
32   end
33 end

Routing in Phoenix

Now we will look at the generated controller at ms_web/controllers/product_controller.ex. Before we look at the controllers, we should look at phoenix routes, which will map the CRUD operations using REST on to the functions defined here. It can be seen in the lib/ms_web/router.ex file. Lets modify it as follows to support REST API, by adding endpoints and routes.

 1 defmodule MsWeb.Router do
 2   use MsWeb, :router
 3 
 4   # The pipeline determines how this request should be processed.
 5   # A pipeline consists of multiple plugs.
 6   # A plug is like a layer/function which takes a connection structure,
 7   # which contains details about the request and outputs another plug.
 8   # It can add things to current connection or delete things from there.
 9   # For example the plug :protect_from_forgery adds some CSRF token to the request.
10 
11   pipeline :api do
12     plug :accepts, ["json"]
13   end
14 
15   # Scope provides our API endpoints, this shows that we got an endpoint at /api.
16   scope "/api", MsWeb do
17     pipe_through :api
18 
19     # Nested scopes, this is hit, when the url looks like https://localhost/api/v1
20     scope "/v1" do
21       # Resource is a shorthand for telling it should support GET, PUT,
22       # POST, DELETE and other methods.
23       # It automatically handles all the HTTP verbs, with their functions.
24       # We can easily see the urls, their HTTP verbs along with executed
25       # function by executing mix phx.routes. We explain it below.
26       resources "/products", ProductController
27     end
28   end
29 end

Listing Routes in Phoenix

Lets now execute mix phx.routes. It will give out the following results

1 product_path  GET     /api/v1/products           MsWeb.ProductController :index
2 product_path  GET     /api/v1/products/:id/edit  MsWeb.ProductController :edit
3 product_path  GET     /api/v1/products/new       MsWeb.ProductController :new
4 product_path  GET     /api/v1/products/:id       MsWeb.ProductController :show
5 product_path  POST    /api/v1/products           MsWeb.ProductController :create
6 product_path  PATCH   /api/v1/products/:id       MsWeb.ProductController :update
7               PUT     /api/v1/products/:id       MsWeb.ProductController :update
8 product_path  DELETE  /api/v1/products/:id       MsWeb.ProductController :delete
9    websocket  WS      /socket/websocket          MsWeb.UserSocket

Like mentioned before, all verbs were generated from the resources /products from router.ex file. If running mix fails for reason, always remember to run it from the directory, where our .mix file is present. Finally we will examine controller.

Mapping Request to Phoenix Controllers

Lets see how a request gets mapped to the functions in the controller. If we send a request like GET /api/v1/products/ it will hit the MsWeb.ProductController.index function. This can be see from the output of mix phx.routes. It always provides the conn(The plug with information about request) and params(The parameters with the request). Any variable which starts with an _ in elixir is considered as a variable, whose value we are not interested in. Since index function doesn’t need any parameters, we can ignore it. We see that it just call _Inventory.list_products() and renders the result via render function call. This will call the render method in the view file.

 1 defmodule MsWeb.ProductController do
 2   use MsWeb, :controller
 3 
 4   # alias allows accessing module Ms.Inventory with just name Inventory
 5   alias Ms.Inventory
 6   alias Ms.Inventory.Product
 7 
 8   action_fallback MsWeb.FallbackController
 9 
10   def index(conn, _params) do
11     products = Inventory.list_products()
12     render(conn, "index.json", products: products)
13   end
14 
15   def create(conn, %{"product" => product_params}) do
16     with {:ok, %Product{} = product} <- Inventory.create_product(product_params) do
17       conn
18       |> put_status(:created)
19       |> put_resp_header("location", Routes.product_path(conn, :show, product))
20       |> render("show.json", product: product)
21     end
22   end
23 
24   def show(conn, %{"id" => id}) do
25     product = Inventory.get_product!(id)
26     render(conn, "show.json", product: product)
27   end
28 
29   def update(conn, %{"id" => id, "product" => product_params}) do
30     product = Inventory.get_product!(id)
31 
32     with {:ok, %Product{} = product} <-
33       Inventory.update_product(product, product_params) do
34       render(conn, "show.json", product: product)
35     end
36   end
37 
38   def delete(conn, %{"id" => id}) do
39     product = Inventory.get_product!(id)
40 
41     with {:ok, %Product{}} <- Inventory.delete_product(product) do
42       send_resp(conn, :no_content, "")
43     end
44   end
45 end

Now lets look at the list_products function. The file is too big, so lets discuss just the first function.

  1 defmodule Ms.Inventory do
  2   @moduledoc """
  3   The Inventory context.
  4   """
  5 
  6   import Ecto.Query, warn: false
  7   alias Ms.Repo
  8 
  9   alias Ms.Inventory.Product
 10 
 11   @doc """
 12   Returns the list of products.
 13 
 14   ## Examples
 15 
 16       iex> list_products()
 17       [%Product{}, ...]
 18 
 19   """
 20   def list_products do
 21     Repo.all(Product)
 22   end
 23 
 24   @doc """
 25   Gets a single product.
 26 
 27   Raises `Ecto.NoResultsError` if the Product does not exist.
 28 
 29   ## Examples
 30 
 31       iex> get_product!(123)
 32       %Product{}
 33 
 34       iex> get_product!(456)
 35       ** (Ecto.NoResultsError)
 36 
 37   """
 38   def get_product!(id), do: Repo.get!(Product, id)
 39 
 40   @doc """
 41   Creates a product.
 42 
 43   ## Examples
 44 
 45       iex> create_product(%{field: value})
 46       {:ok, %Product{}}
 47 
 48       iex> create_product(%{field: bad_value})
 49       {:error, %Ecto.Changeset{}}
 50 
 51   """
 52   def create_product(attrs \\ %{}) do
 53     %Product{}
 54     |> Product.changeset(attrs)
 55     |> Repo.insert()
 56   end
 57 
 58   @doc """
 59   Updates a product.
 60 
 61   ## Examples
 62 
 63       iex> update_product(product, %{field: new_value})
 64       {:ok, %Product{}}
 65 
 66       iex> update_product(product, %{field: bad_value})
 67       {:error, %Ecto.Changeset{}}
 68 
 69   """
 70   def update_product(%Product{} = product, attrs) do
 71     product
 72     |> Product.changeset(attrs)
 73     |> Repo.update()
 74   end
 75 
 76   @doc """
 77   Deletes a Product.
 78 
 79   ## Examples
 80 
 81       iex> delete_product(product)
 82       {:ok, %Product{}}
 83 
 84       iex> delete_product(product)
 85       {:error, %Ecto.Changeset{}}
 86 
 87   """
 88   def delete_product(%Product{} = product) do
 89     Repo.delete(product)
 90   end
 91 
 92   @doc """
 93   Returns an `%Ecto.Changeset{}` for tracking product changes.
 94 
 95   ## Examples
 96 
 97       iex> change_product(product)
 98       %Ecto.Changeset{source: %Product{}}
 99 
100   """
101   def change_product(%Product{} = product) do
102     Product.changeset(product, %{})
103   end
104 end

We see that list_products just calls Repo.all(Product). This is a function to query the database. What it means is that get me all the rows with Product schema. So now we know when we hit the index function, we will get all the data about that specific model. Similarly to get a row using an id, we use Repo.get(), and to delete a value we use Repo.delete(). For updation, we use Repo.update. Here we simply update the changeset with the new data and insert it to the database. Changesets are a very useful concept, for making database operations cleaner and also offers a standard interface to work with database operations.

At this point, I believe we covered the required basics for working with Phoenix and Ecto. More concepts will be explained as we progress through the tutorial. Lets go ahead and generate the other tables. All generated files follow a similar structure.

1 mix phx.gen.json Inventory Brand brands name:string details:map

An astute, reader will notice that the Product model, we generated before, didn’t include a brandid. It was on purpose, first we need to generate a brands table to provide a reference to it. Now somehow we need to integrate this into our already existing _Product* model. We can use the _ecto.gen.migration task to do it. Lets create a new migration as follows.

1 mix ecto.gen.migration add_product_details

Now there will be a file with suffix _add_product_details.exs in the priv/migrations/ folder. Lets add the brand_id and some details to the migration. Edit the file as below.

 1 defmodule Ms.Repo.Migrations.AddProductDetails do
 2   use Ecto.Migration
 3 
 4   def change do
 5     alter table(:products) do
 6       add :brand_id, references(:brands, on_delete: :delete_all)
 7       add :details, :map
 8     end
 9   end
10 end

As you can see the brand_id references the brands table. It is a good time to run migrations for already generated models.

1 mix ecto.migrate

Now we need to modify the Product schema to include the brand_id and details. Lets change it as follows.

 1 defmodule Ms.Inventory.Product do
 2   use Ecto.Schema
 3   import Ecto.Changeset
 4 
 5   schema "products" do
 6     field :name, :string
 7     field :price, :float
 8     field :stock, :integer
 9     field :tax, :float
10     field :details, :map # Newly added details
11     belongs_to(:brand, Ms.Inventory.Brand) # Foreign key for brand
12 
13     timestamps()
14   end
15 
16   @doc false
17   def changeset(product, attrs) do
18     product
19     |> cast(attrs, [:price, :stock, :name, :tax, :details])
20     |> validate_required([:price, :stock, :name, :tax])
21   end
22 end

You will also notice we didn’t add brand_id to cast. It is because adding brand_id will require a brand_id to exist before we create a product. This will be fixed in later sections. Now lets generate the other tables.

 1 # The details:map is of type map, which in PostgreSQL is JSON. So details will be a \
 2 JSON field.
 3 mix phx.gen.json CustomerManagement Customer customers \
 4   name:string phone:string pincode:string details:map
 5 mix ecto.migrate
 6 
 7 # The argument, customer:references:customers says that the field
 8 # *customer* is a reference to table customers.
 9 mix phx.gen.json OrderManagement Order orders \
10   customer:references:customers creationDate:utc_datetime message:string details:map
11 mix ecto.migrate
12 
13 mix phx.gen.json OrderManagement OrderItem order_items \
14   product:references:products amount:integer unitPrice:float order:references:orders
15 mix ecto.migrate
16 
17 mix phx.gen.json DeliveryManagement Delivery deliveries \
18   orderitem:references:order_items fare:float address:map \
19   details:map customer:references:customers
20 mix ecto.migrate

Now we will add paths for newly generated models as follows to the /api/v1/.

1     scope "/v1" do
2       resources "/products", ProductController
3       resources "/brands", BrandController
4       resources "/orders", OrderController
5       resources "/customers", CustomerController
6       resources "/order_items", OrderItemController
7       resources "/deliveries", DeliveryController
8     end

Now we have a bare minimum backend, which will respond to our HTTP requests. As you notice our models have some limitations, which we will address in the coming up chapters.

Generate Vue Typescript Application

Getting started with VueJS and Typescript

Typescript is a superset of Javascript with support for static typing. Static typing can help us in avoiding a category of bugs and also offer very good support with refactoring of code. For this project we will use vue-cli 3 to generate our Vue app. To install vue-cli, issue the following command.

1 npm install -g @vue/cli

Once we have installed, it is time to create our project.

Creating the Project

A new project can be created using vue create.

1 vue create shop

The console will ask a few questions and once it is finished we have a bare minimum working VueJS app. I chose Typescript, vue-router, vuex, dart-sass, babel, pwa, unit-jest and e2e-cypress. Once it is finished start the dev server.

Starting Vue Development Server

1 cd shop
2 npm run serve

Now we got the vue running at port 8080.

Ok, so now we got vue running, but how does it work ?. Lets take a dive into the basic concepts and working of Vue with Typescript.

Vue starts its execution from the main.ts file inside the src/main.ts file.

 1 import Vue from "vue";
 2 import App from "./App.vue";
 3 import router from "./router";
 4 import store from "./store";
 5 import "./registerServiceWorker";
 6 
 7 Vue.config.productionTip = false;
 8 
 9 new Vue({
10   router,
11   store,
12   render: (h) => h(App),
13 }).$mount("#app");

Routing with Vue-router

Here we make a new Vue instance with router, store and an app. Router takes care of the routing to views according to the url.

src/router.ts

 1 import Vue from "vue";
 2 import Router from "vue-router";
 3 import Home from "./views/Home.vue";
 4 
 5 Vue.use(Router);
 6 
 7 export default new Router({
 8   mode: "history",
 9   base: process.env.BASE_URL,
10   routes: [
11     {
12       path: "/",
13       name: "home",
14       component: Home,
15     },
16     {
17       path: "/about",
18       name: "about",
19       // this generates a separate chunk (about.[hash].js) for this route
20       // which is lazy-loaded when the route is visited.
21       component: () =>
22         import(/* webpackChunkName: "about" */ "./views/About.vue"),
23     },
24   ],
25 });

Vue Components

As you can see here when the path is /(root) we route to component Home. Components are self-contained, reusable blocks in Vue. Vue components have a template part, which resides inside <template> tag, where we will write html elements. Another part <style> takes care of the styling and the <script> tag part holds the logic for the component. The files ending with .vue are single-file components. Here we see Home.vue file is imported. Lets see what is inside it, so that we know what we will get when we visit the /.

 1 <template>
 2   <div class="home">
 3     <img src="../assets/logo.png" />
 4     <HelloWorld msg="Welcome to Your Vue.js + TypeScript App" />
 5   </div>
 6 </template>
 7 
 8 <script lang="ts">
 9   import { Component, Vue } from "vue-property-decorator";
10   import HelloWorld from "@/components/HelloWorld.vue"; // @ is an alias to /src
11 
12   @Component({
13     components: {
14       HelloWorld,
15     },
16   })
17   export default class Home extends Vue {}
18 </script>

We see HelloWorld imported from components folder. And it is passed to @Component Typescript decorator. The @Component is a decorator, which tells vue which components are used in the current component, ie. inside Home component. Since we only use the HelloWorld, we list it there. And we use the HelloWorld component inside the <template> tag. It is like embedding the contents of that component, inside our own. You can consider it as small building blocks which make up our current view.

Lets look at the HelloWorld component itself. I omitted unimportant pieces below, like styling information and links.

 1 <template>
 2   <div class="hello">
 3     <h1>{{ msg }}</h1>
 4   </div>
 5 </template>
 6 
 7 <script lang="ts">
 8   import { Component, Prop, Vue } from "vue-property-decorator";
 9 
10   @Component
11   export default class HelloWorld extends Vue {
12     @Prop() private msg!: string;
13   }
14 </script>
15 
16 <!-- Add "scoped" attribute to limit CSS to this component only -->
17 <style scoped lang="scss"></style>

Vue Component Props

Here we see a new decorator, the @Prop decorator. It is another decorator for making use of vue props. Props are messages which we pass to the components. For example here we see that we are using {{msg}} inside our <template>, but we haven’t assigned a value to it anywhere in our component. So from where, will we get this value ?. The props is the answer. Component which embeds this components provides it to us via props. In this case the Home component gives it to us like below. Notice the msg=…value.

1 <HelloWorld msg="Welcome to Your Vue.js + TypeScript App" />

So our {{msg}} gets the value Welcome to Your Vue.js …..

State management in Vue

Store is like a database where you store data from components. The store we will use is Vuex. Vuex is a reactive store, which means all components which use a specific data will be automatically updated, if that data changes.

src/store.ts

 1 import Vue from "vue";
 2 import Vuex from "vuex";
 3 
 4 Vue.use(Vuex);
 5 
 6 export default new Vuex.Store({
 7   state: {},
 8   mutations: {},
 9   actions: {},
10 });

We will discuss the state, mutations and actions in a later part of the tutorial. If we notice the main.ts file, it passes the App.vue into the render function and mounts it to #app id in the public/index.html file. This means App.vue is the main/entry file.

 1 <template>
 2   <div id="app">
 3     <div id="nav">
 4       <router-link to="/">Home</router-link> |
 5       <router-link to="/about">About</router-link>
 6     </div>
 7     <router-view />
 8   </div>
 9 </template>
10 <script></script>
11 <style></style>

Inside the App.vue file we have two router-links. The router-link is a like a a href for router. router-link modifies the router view based on this link in accordance with the router.ts we discussed earlier. This results in changing of displayed component when the link is clicked. This can be seen by clicking the About button, which points to /about path, which is mapped to views/About.vue component inside router.ts.

The only step left is to mount the vue to some tag in HTML file, which is done by the $mount. Here it mounts to tag with id #app. Head over to Official docs for an in depth explanation of Vue. The source code for client is available here.

In next chapter, we will discuss about adding multi language support to Vue.

Add Multi-language support

Setting up multi-language support

The inventory manager we will be building should be able to support multiple languages. We use vue-i18n, which comes with typescript support. It can be added using vue-cli.

1 vue add i18n

Here we choose to enable single page i18n support and provide de as fallback language. The resulting directory structure is as follows.

 1 src
 2 ├── App.vue
 3 ├── assets
 4 │   └── logo.png
 5 ├── components
 6 │   ├── HelloI18n.vue
 7 │   └── HelloWorld.vue
 8 ├── i18n.ts
 9 ├── locales
10 │   ├── de.json
11 │   └── en.json
12 ├── main.ts
13 ├── registerServiceWorker.ts
14 ├── router.ts
15 ├── shims-tsx.d.ts
16 ├── shims-vue.d.ts
17 ├── store.ts
18 └── views
19     ├── About.vue
20     └── Home.vue

i18n adds some new files which injects i18n into Vue app. The i18n.ts will automatically load i18n based on vue.config.js. The .env can be used to put the environment variable to configure the application. More info can be found here. The src/main.ts will be as follows.

 1 import Vue from "vue";
 2 import App from "./App.vue";
 3 import router from "./router";
 4 import store from "./store";
 5 import "./registerServiceWorker";
 6 import i18n from "./i18n";
 7 
 8 Vue.config.productionTip = false;
 9 
10 new Vue({
11   router,
12   store,
13   i18n,
14   render: (h) => h(App),
15 }).$mount("#app");

We also got a new locales folder for storing our translations. Currently we have de.json and en.json corresponding to German and English respectively.

Contents of locales/de.json is as follows.

1 {
2   "message": "hallo von JSON"
3 }

Now lets move the content of newly created HelloI18n.vue to About.vue, so that we can see it action. We also take value from locales/de.json in $t(‘message’), as we haven’t provided translations in en and Vue fallbacks to de. The resulting About.vue is as follows.

 1 <template>
 2   <div class="about">
 3     <h1>This is an about page</h1>
 4     <p>{{ $t('hello') }}</p>
 5     <p>{{ $t('message') }}</p>
 6     <!-- Comes from locales/de.json -->
 7   </div>
 8 </template>
 9 
10 <script lang="ts">
11   import { Component, Vue } from "vue-property-decorator";
12 
13   @Component
14   export default class HelloI18n extends Vue {}
15 </script>
16 
17 // This tag allows embedding translations directly in .vue files.
18 <i18n>
19   { "de": { "hello": "Hallo i18n von SFC!" } }
20 </i18n>

Now we should see a Hallo i18n von SFC! in our About page along with Hallo von JSON.

In next chapter, we will write our first Vue component.

Building our Homepage view component

Visual Design of Our App

Before we start coding, lets take a look at a rough sketch of our application. The homepage should look as below.

Home
Home

All the blocks except the search bar are buttons. Clicking on these buttons should take us to a different view/component. For example clicking on Products block should get us to Product list view which might look as below.

Product List
Product List

So lets get started building a the home screen Home.vue.

Our first Vue component

Our component will be very simple, as it just have a few links which will take us to respective components as shown above. We will use bulma for building our interfaces.

1 npm install bulma

This will install bulma and we also need to use font-awesome for our fonts. We will directly link to the CDN inside our index.html file header as shown below. Other parts are ommitted.

 1 <head>
 2   <meta charset="utf-8" />
 3   <meta http-equiv="X-UA-Compatible" content="IE=edge" />
 4   <meta name="viewport" content="width=device-width,initial-scale=1.0" />
 5   <link rel="icon" href="<%= BASE_URL %>favicon.ico" />
 6   <title>shop</title>
 7   <script
 8     defer
 9     src="https://use.fontawesome.com/releases/v5.3.1/js/all.js"
10   ></script>
11 </head>

In order to link to pages, we will use router-link. The code is explained in the comments inside the Home.vue file. We will use bulma tiles for aligning our boxes. The working of tiles is explained here. Fontawesome icons are used inside the boxes.

 1 <template>
 2   <div class="container">
 3     <div class="tile is-ancestor">
 4       <!-- The ancestor of all tiles -->
 5       <div class="tile is-parent">
 6         <!-- Parent of all child classes -->
 7         <article class="tile is-child box">
 8           <!-- child where we show our tile -->
 9           <!-- We need at least 3 levels of hierarchy. 
10                ie. is-ancestor -> is-parent -> is-child -->
11           <router-link to="/customers">
12             <p class="title">Customers</p>
13             <p class="subtitle">Customer Management</p>
14             <div class="content">
15               <i class="fas fa-users fa-5x"></i>
16             </div>
17           </router-link>
18         </article>
19       </div>
20       <div class="tile is-parent">
21         <article class="tile is-child box">
22           <router-link to="/products">
23             <p class="title">Products</p>
24             <p class="subtitle">Inventory Management</p>
25             <div class="content">
26               <i class="fas fa-warehouse fa-5x"></i>
27             </div>
28           </router-link>
29         </article>
30       </div>
31 
32       <div class="tile is-parent">
33         <article class="tile is-child box">
34           <router-link to="/orders">
35             <p class="title">Orders</p>
36             <p class="subtitle">Order Management</p>
37             <div class="content">
38               <i class="fas fa-boxes fa-5x"></i>
39             </div>
40           </router-link>
41         </article>
42       </div>
43       <div class="tile is-parent">
44         <article class="tile is-child box">
45           <router-link to="/deliveries">
46             <p class="title">Delivery</p>
47             <p class="subtitle">Delivery Management</p>
48             <div class="content">
49               <i class="fas fa-truck-moving fa-5x"></i>
50             </div>
51           </router-link>
52         </article>
53       </div>
54     </div>
55   </div>
56 </template>
57 
58 <script lang="ts">
59   import { Component, Vue } from "vue-property-decorator";
60 
61   @Component({
62     components: {},
63   })
64   export default class Home extends Vue {}
65 </script>

Now our webpage should look as follows.

final webpage
final webpage

Thats all for this chapter. We will add our search bar later, once we have our backend ready. In the next chapter, we will start building our inventory management related components, like adding of products and viewing of product lists.

Adding Vuex to Vue

Now we get to the meat of our application. We need to add Products to our inventory. Lets start with writing AddProduct.vue component. But before we write an AddProduct component, it would be better to build a ProductView.vue component, which can display a product. Then we can embed this component into our AddProduct component, providing us with a single component for adding/editing and viewing products.

Two-binding in Vue with v-model

The component is introduces a few new concepts. The first is the v-model. v-model creates a two-way data binding with inputs and component data. In our case we bind <input id=”productName”/> with currentProduct.name variable. currentProduct is a product object passed as a prop. Props enable to pass data from parent component to child component. Here our component ProductView.vue is supposed to be embedded in AddProduct.vue component. AddProduct.vue component will thus provide us with the value for currentProduct which our ProductView.vue component will simply display and bind to its <input>. As we can see in below example we binding all product attributes to input fields in our template. v-model also automatically takes care of updating the data based on input fields. The {{variable}} format puts the output of evaluation of Javascript expression variable into the html. It belongs to the template syntax.

 1 <template>
 2   <div>
 3     <div class="field">
 4       <div class="control">
 5         <!-- {{variable}} is used to put the contents of variable into the html -->
 6         <label class="label" for="productName"
 7           >{{ $t('productName.label') }}</label
 8         >
 9         <!-- We bind currentProduct.name to this input field -->
10         <input
11           class="input"
12           id="productName"
13           type="text"
14           v-model="currentProduct.name"
15         />
16       </div>
17     </div>
18 
19     <div class="field">
20       <div class="control">
21         <label for="productPrice" class="label"
22           >{{ $t('productPrice.label') }}
23         </label>
24         <input
25           class="input"
26           id="productPrice"
27           type="text"
28           v-model="currentProduct.price"
29         />
30       </div>
31     </div>
32 
33     <div class="field">
34       <div class="control">
35         <label class="label" for="productStock"
36           >{{ $t('productStock.label') }}
37         </label>
38         <input
39           class="input"
40           id="productStock"
41           type="text"
42           v-model="currentProduct.stock"
43         />
44       </div>
45     </div>
46 
47     <div class="field">
48       <div class="control">
49         <label class="label" for="productTax"
50           >{{ $t('productTax.label') }}</label
51         >
52         <input
53           class="input"
54           id="productTax"
55           type="text"
56           v-model="currentProduct.tax"
57         />
58       </div>
59     </div>
60 
61     <div class="field">
62       <div class="control">
63         <slot></slot>
64       </div>
65     </div>
66   </div>
67 </template>
68 
69 <script lang="ts">
70   import { Product } from "@/types/types.ts";
71   import { Component, Prop } from "vue-property-decorator";
72   import Vue from "vue";
73 
74   @Component({
75     components: {},
76   })
77   export default class ProductView extends Vue {
78     // Prop are data values that are passed from parent component to child component
79     @Prop()
80     public currentProduct!: Product;
81   }
82 </script>
83 
84 <style lang="sass" scoped></style>
85 
86 <i18n>
87   { "de": { "productName": { "label": "Produkt Name" }, "productPrice": {
88   "label": "Produkt Preis" }, "productStock": { "label": "Produkt Stock" },
89   "productDetail": { "label": "Produkt Detail" }, "productTax": { "label":
90   "Produkt Steuer" } }, "en": { "productName": { "label": "Product Name" },
91   "productPrice": { "label": "Product Price" }, "productStock": { "label":
92   "Product Stock" }, "productDetail": { "label": "Product Detail" },
93   "productTax": { "label": "Product Tax" } } }
94 </i18n>

Now lets turn our attention to AddProduct.vue component.

 1 <template>
 2   <div class="container">
 3     <form>
 4       <ProductView :currentProduct="this.currentProduct">
 5         <input
 6           class="button is-black"
 7           value="Send"
 8           type="submit"
 9           v-on:click.prevent="onSubmit"
10         />
11       </ProductView>
12     </form>
13   </div>
14 </template>
15 
16 <script lang="ts">
17   import Vue from "vue";
18   import { Product } from "@/types/types.ts";
19   import ProductView from "@/components/product/ProductView.vue";
20   import { Component, Prop } from "vue-property-decorator";
21   import products from "@/store/modules/products";
22 
23   @Component({
24     components: {
25       ProductView,
26     },
27   })
28   export default class AddProduct extends Vue {
29     private currentProduct: Product = products.service.getEmpty();
30 
31     public async onSubmit() {
32       const response = await products.service.createProduct(
33         this.currentProduct
34       );
35     }
36   }
37 </script>

As we can see ProductView is embedded inside this component with :currentProduct props set to this.currentProduct. currentProduct is initialized from productService.ts. We also notice that the button click is bound to onSubmit() function using the v-on directive. v-on directive can be used to listen to DOM events and respond with running of some Javascript code. onSubmit is an asynchronous function which calls a webservice. async/await enables to handle callback based events in an intuitive linear fashion. An async function returns a Promise. Mozilla docs describes a Promise as an object that represents the eventual completion (or failure) of an asynchronous operation, and its resulting value. In an async function an await call waits for the promise to be completed and provides the value of promise. In our case the await call waits till the response from the backend comes back. Currently we ignore the output from server, but in future we will handle those.

State Management with Vuex

Before we move onto see how the request is sent and how the backend handles it, lets assume we got back the response and see how to store the response at our client side. This is useful because, we don’t have to always request the server to give us new data and show the user our cached data at client side. Vuex is the official goto solution for state management in Vue. It can act as a central store in an application, with state being automatically updated for all components. It also provides functions like getters, mutations and actions which provides a uniform interface for working with state. In typescript we use the vuex-module-decorators for working with Vuex. In order to install use the below command.

1 npm install -D vuex-module-decorators

Vuex got five main parts.

  • State - This is the data we want vuex to keep. It acts as a single source of truth. Vuex store everything in a single object which acts a tree storing all the data.
  • Getters - Functions which can retrieve state from the store and compute properties from retrieved state.
  • Mutations - Way to change the state of Vuex. They are synchronous. Mutations have a string type and a handler. Handlers performs operations on state. Types are used to identify the mutation.
  • Actions - Actions commit mutations. They are asynchronous. We commit mutations inside actions, once we have the required data.
  • Modules - In order to keep state manageable we can break down the state into modules. Each module contains its own State, Getters, Mutations and Actions.
Vuex life cycle
Vuex life cycle

The above picture from Vuex homepage show the basic architecture of Vuex. As we can see components dispatch Actions which commits a Mutation to State.

Our basic store is defined at src/store/index.ts. This is an empty store. We will dynamically register modules to this store while we are declaring the modules.

 1 import Vue from "vue";
 2 import Vuex from "vuex";
 3 
 4 Vue.use(Vuex);
 5 
 6 export default new Vuex.Store({
 7   state: {},
 8   mutations: {},
 9   actions: {},
10 });

Now lets take a look at our Vuex module defined at src/store/modules/products.ts. Please take a look at inline comments for better understanding of code.

 1 import {
 2   VuexModule,
 3   Module,
 4   getModule,
 5   Mutation,
 6   Action,
 7 } from "vuex-module-decorators";
 8 import store from "@/store";
 9 import { Product } from "@/types/types";
10 import { ProductService } from "@/services/productService";
11 
12 /**
13  * When namespaced is set to true, contents of the module is automatically
14  * namespaced based on name. Vuex allows adding modules to store
15  * dynamically after store is created, if we set dynamic flag to true.
16  * store is the Vuex store to which we need to insert our module.
17  */
18 @Module({
19   namespaced: true,
20   name: "products",
21   store,
22   dynamic: true,
23 })
24 class ProductModule extends VuexModule {
25   /**
26    * Class variables automatically become part of the state.
27    * Our module thus will have products/allProducts and products/service
28    * as state variables. The reason we put service inside Vuex is because,
29    * in this case there will be only one instance of ProductService
30    * which can be shared between all components.
31    */
32 
33   public allProducts: Product[] = [];
34   public service: ProductService = new ProductService(
35     "http://0.0.0.0:4000/api/v1/",
36     "products"
37   );
38 
39   // Action automatically calls setProducts function
40   // with arguments returned by fetchProducts function.
41   @Action({ commit: "setProducts" })
42   public async fetchProducts() {
43     // Calls into service to get all products
44     const t = await this.service.getAllRequest();
45     return t.data.products;
46   }
47 
48   // modifies our module state, by setting allProducts to p.
49   @Mutation
50   public setProducts(p: Product[]) {
51     this.allProducts = p;
52   }
53 }
54 
55 // Export our module so our components can use it.
56 export default getModule(ProductModule);

In my opinion vuex-module-decorators module take away a lot of pain when working with Vuex :).

Now lets look at how our ProductService.ts that talks with the backend and creates/retrieves a product. ProductService.ts resides in src/services folder.

HTTP Requests with Axios

In order to talk with backend we use the axios library. It can be installed as follows.

1 npm install axios
 1 import axios, { AxiosPromise } from "axios";
 2 import { Product, getEmptyProduct } from "@/types/types";
 3 
 4 export class ProductService {
 5   private endpoint: string;
 6   private entity: string;
 7 
 8   constructor(endpoint: string, entity: string) {
 9     this.endpoint = endpoint;
10     this.entity = entity;
11   }
12 
13   public getAllRequest(): AxiosPromise<{ products: Product[] }> {
14     const response = axios.get(`${this.endpoint}${this.entity}`);
15     return response;
16   }
17 
18   public createProduct(data: Product): AxiosPromise<{ product: Product }> {
19     return axios.post(`${this.endpoint}${this.entity}`, { product: data });
20   }
21 
22   public updateProduct(
23     identifier: number,
24     data: Product
25   ): AxiosPromise<{ product: Product }> {
26     return axios.put(`${this.endpoint}${this.entity}/${identifier}`, {
27       product: data,
28     });
29   }
30 
31   public getProduct(identifier: number): AxiosPromise<{ product: Product }> {
32     return axios.get(`${this.endpoint}${this.entity}/${identifier}`);
33   }
34 
35   public deleteProduct(identifier: number): AxiosPromise<any> {
36     return axios.delete(`${this.endpoint}${this.entity}/${identifier}`);
37   }
38 
39   public getEmpty(): Product {
40     return getEmptyProduct();
41   }
42 }

Here the createProduct function posts the data to the backend service and returns back an AxiosPromise. It contains the output in JSON form from the backend.

HTTP Request Handling with Phoenix

Lets see how this request will be handled by the Phoenix server. As can be seen in src/store/modules/product.ts we have endpoint set to “http://0.0.0.0:4000/api/v1/” and entity set to “products”. So our createProduct will send a POST request to /api/v1/products with the data which is of type Product. Our backend matches this with MsWeb.ProductController :create function. As discussed before we can see this by running below command.

1 mix phx.routes
2 product_path  POST    /api/v1/products              MsWeb.ProductController :create
1 defmodule MsWeb.ProductController do
2   def create(conn, %{"product" => product_params}) do
3     with {:ok, %Product{} = product} <- Inventory.create_product(product_params) do
4       conn
5       |> put_status(:created)
6       |> put_resp_header("location", Routes.product_path(conn, :show, product))
7       |> render("show.json", product: product)
8     end
9   end

This function will store our data in database and render the JSON which is returned in response. Notice we changed data to product in render(“show.json”). This is just a personal preference.

 1 defmodule MsWeb.ProductView do
 2   use MsWeb, :view
 3   alias MsWeb.ProductView
 4 
 5   def render("index.json", %{products: products}) do
 6     %{products: render_many(products, ProductView, "product.json")}
 7   end
 8 
 9   def render("show.json", %{product: product}) do
10     %{product: render_one(product, ProductView, "product.json")}
11   end
12 
13   def render("product.json", %{product: product}) do
14     %{id: product.id,
15       price: product.price,
16       stock: product.stock,
17       name: product.name,
18       tax: product.tax}
19   end
20 end

Similarly we do the same for customer_view.ex by changing data to customer.

 1 defmodule MsWeb.CustomerView do
 2   use MsWeb, :view
 3   alias MsWeb.CustomerView
 4 
 5   def render("index.json", %{customers: customers}) do
 6     %{customers: render_many(customers, CustomerView, "customer.json")}
 7   end
 8 
 9   def render("show.json", %{customer: customer}) do
10     %{customer: render_one(customer, CustomerView, "customer.json")}
11   end
12 
13   def render("customer.json", %{customer: customer}) do
14     %{id: customer.id,
15       name: customer.name,
16       phone: customer.phone,
17       pincode: customer.pincode,
18       details: customer.details}
19   end
20 end

As you can see our response will have an id, price, stock, name and tax information. Looking at our createProducts signature we know the response will be of type AxiosPromise<{ “product”: Product }>. Now only thing we need to do is to define a Product class with this structure and Typescript will provide us with a Product object from JSON response.

Lets take a look at our defined classes corresponding to Products, Brands etc. These classes are designed based on the structure of JSON received from the backend. One of the major benefits of Typescript is the enforcing of structure for data. For example, in our case we know the attributes a Product/Brand holds. This enables us to easily convert the JSON received from a web-service into respective objects. This greatly improves the refactorability and maintainability of our code. The types.ts file is present at src/types folder.

Here is our Product definition for Typescript.

 1 export interface Product {
 2   updated_at: Date;
 3   tax: number;
 4   stock: number;
 5   price: number;
 6   name: string;
 7   inserted_at: Date;
 8   id: number;
 9   details: object;
10   brand_id: number;
11 }

Ecto Schemas

If you are wondering, where this structure comes from, it comes from the database layout of our backend. For example consider the lib/ms/inventory/product.ex file from phoenix project.

 1 defmodule Ms.Inventory.Product do
 2 
 3   # Other irrelevant parts removed.
 4   schema "products" do
 5     field :name, :string
 6     field :price, :float
 7     field :stock, :integer
 8     field :tax, :float
 9     field :details, :map
10     belongs_to(:brand, Ms.Inventory.Brand)
11 
12     timestamps()
13   end
14 end

Inside the schema for products, the timestamps() macro adds updated_at and inserted_at fields. id is automatically generated by ecto. Other fields like tax, stock, price etc are directly present in the schema.

Converting Ecto Schemas to Typescript Interfaces

We simply convert the types from Elixir/Ecto to Typescript. ie. float | integer -> number map -> object string -> string

Similarly we can convert the all Ecto schemas to Typescript. There are also other ways to generate Typescript schemas like deriving schema from the JSON response from the server. For example, this page can generate typescript schemas from JSON. Here is a full listing of the whole types/types.ts file. We have also defined a factory methods to the make an empty product.

 1 export interface Product {
 2   updated_at: Date;
 3   tax: number;
 4   stock: number;
 5   price: number;
 6   name: string;
 7   inserted_at: Date;
 8   id: number;
 9   details: object;
10   brand_id: number;
11 }
12 export interface OrderItem {
13   updated_at: Date;
14   unitPrice: number;
15   product_id: number;
16   order_id: number;
17   inserted_at: Date;
18   id: number;
19   amount: number;
20 }
21 export interface Order {
22   updated_at: Date;
23   message: string;
24   inserted_at: Date;
25   id: number;
26   details: object;
27   customer_id: number;
28   creationDate: Date;
29 }
30 export interface Brand {
31   updated_at: Date;
32   name: string;
33   inserted_at: Date;
34   id: number;
35   details: object;
36 }
37 export interface Delivery {
38   updated_at: Date;
39   order_id: number;
40   inserted_at: Date;
41   id: number;
42   fare: number;
43   details: object;
44   customer_id: number;
45   address: object;
46 }
47 export interface Customer {
48   updated_at: Date;
49   pincode: string;
50   phone: string;
51   name: string;
52   inserted_at: Date;
53   id: number;
54   details: object;
55 }
56 
57 class ProductImpl implements Product {
58   public name: string;
59   public stock: number;
60   public tax: number;
61   public price: number;
62   public details: object;
63   public brand_id: number;
64   public id: number;
65   public updated_at: Date;
66   public inserted_at: Date;
67 
68   constructor(
69     name = "",
70     stock = 0,
71     tax = 0,
72     price = 0,
73     details = {},
74     brand_id = -1,
75     id = -1
76   ) {
77     this.name = name;
78     this.stock = stock;
79     this.tax = tax;
80     this.price = price;
81     this.details = details;
82     this.brand_id = brand_id;
83     this.id = id;
84     this.updated_at = new Date();
85     this.inserted_at = new Date();
86   }
87 }
88 
89 // Factory method
90 export function getEmptyProduct() {
91   return new ProductImpl();
92 }

If we run our code now, we will notice that the server rejects our request. This is due to CORS security mechanisms.

Adding CORS to Phoenix Server

In order to allow some service A, running in a server A, to access service B in a server B, the server B should provide permissions to service A. The browsers check these permissions before it makes a web request. This is to improve the security of webservices. By default Phoenix doesn’t allow CORS. In our since we are developing our frontend and backend separately, we need allow CORS in service A.

To enable CORS we use cors_plug. To use we add the following to deps function in mix.exs file.

1 def deps do
2   # ...
3   {:cors_plug, "~> 2.0"},
4   #...
5 end

Now get our new dependency using below command.

1 mix deps.get

Since we need to allow CORS for all routes we add CORS plug to lib/ms/endpoint.ex file.

1 defmodule MsWeb.Endpoint do
2   use Phoenix.Endpoint, otp_app: :your_app
3 
4   # Enable CORS for Endpoint
5   plug CORSPlug
6 
7   plug MsWeb.Router
8 end

Listing all Products

 1 <template>
 2   <div>
 3     <li v-for="product in this.productsList" v-bind:key="product.id">
 4       <ProductView :currentProduct="product" />
 5     </li>
 6   </div>
 7 </template>
 8 
 9 <script lang="ts">
10   import { Product, getEmptyProduct } from "@/types/types";
11   import { Component, Prop } from "vue-property-decorator";
12   import Vue from "vue";
13   import products from "@/store/modules/products";
14   import ProductView from "@/components/product/ProductView.vue";
15 
16   @Component({
17     components: {
18       ProductView,
19     },
20   })
21   export default class ProductList extends Vue {
22     public created() {
23       products.fetchProducts();
24     }
25 
26     get productsList(): Product[] {
27       return products.allProducts;
28     }
29   }
30 </script>

Notice we reused the ProductView again in here. We will soon replace it with a better one in next section.

Adding Components to Vue-Router

Now the only thing remaining is to add our new components to our router file.

 1 import Vue from "vue";
 2 import Router from "vue-router";
 3 import Home from "./views/Home.vue";
 4 
 5 Vue.use(Router);
 6 
 7 export default new Router({
 8   mode: "history",
 9   base: process.env.BASE_URL,
10   routes: [
11     {
12       path: "/",
13       name: "home",
14       component: Home,
15     },
16     {
17       path: "/about",
18       name: "about",
19       component: () => import("./views/About.vue"),
20     },
21     {
22       path: "/products",
23       name: "products",
24       component: () => import("./components/product/ProductList.vue"),
25     },
26     {
27       path: "/products/add",
28       name: "add-product",
29       component: () => import("./components/product/AddProduct.vue"),
30     },
31   ],
32 });

Now we can go to add-product to add new products. It should look as below.

add product
add product

We can also go to products to see all of our products.

list products
list products

Since we’ve come this far, lets implement edit the functionality for products. Implementing EditProduct.vue would be straightforward as can reuse our ProductView.vue. The EditProduct component should take a product_id and should enable us to fetch and update that product. The product_id should be fetched from the URL using vue-router. For example if we need to edit the product with product_id 1, we will have to go to te URL http://localhost:8081/products/edit/1.

Below are the contents of src/components/product/EditProduct.vue

 1 <template>
 2   <div class="container">
 3     <form v-if="this.currentProduct">
 4       <ProductView :currentProduct="this.currentProduct">
 5         <input
 6           class="button is-black"
 7           value="Send"
 8           type="submit"
 9           v-on:click.prevent="onSubmit"
10         />
11       </ProductView>
12     </form>
13   </div>
14 </template>
15 
16 <script lang="ts">
17   import Vue from "vue";
18   import { Product } from "@/types/types.ts";
19   import { ProductService } from "@/services/productService.ts";
20   import ProductView from "@/components/product/ProductView.vue";
21   import { Component, Prop } from "vue-property-decorator";
22   import products from "@/store/modules/products";
23 
24   @Component({
25     components: {
26       ProductView,
27     },
28   })
29   export default class EditProduct extends Vue {
30     // The ! is used to tell typescript that, we will provide a value for id and
31     // it don't have to check for initialization of variable id
32     @Prop()
33     private id!: number;
34     // Union types allows the currentProduct to be either a Product or a null value.
35     // See https://mariusschulz.com/blog/typescript-2-0-non-nullable-types
36     private currentProduct: Product | null = null;
37 
38     // We still need the v-if because, even though created() is called by Vue
39     // synchronously, the function called inside created() is asynchronous and
40     // can be finish after mounted() and can cause rendering warnings.
41     // See https://stackoverflow.com/questions/49577394/
42     public async created() {
43       const response = await products.service.getProduct(this.id);
44       this.currentProduct = response.data.product;
45     }
46 
47     public async onSubmit() {
48       // Typescript allows property access of nullable types based on conditions.
49       // See https://mariusschulz.com/blog/typescript-2-0-non-nullable-types
50       if (this.currentProduct) {
51         const response = await products.service.updateProduct(
52           this.id,
53           this.currentProduct
54         );
55       }
56     }
57   }
58 </script>
59 
60 <style lang="sass" scoped></style>

Vue enables conditional rendering of a block using v-if directive. In our case the <form> element will only be rendered when the v-if= condition this.currentProduct evaluates to True. As you can see, we are fetching the product data inside the created() life-cycle hook provided by Vue. created() is called by Vue after our component was instantiated. Vue also provides other hooks like beforeMount and mounted and more. Refer Vue life cycle hooks for more information. As we can see from productService.ts, updateProduct just send a PUT request to api/v1/products/1, similar to createProduct discussed above.

Now the only step remaining is to see how to retrieve the id(1 here) from a URL like http://localhost:8081/products/edit/1 using vue-router. Just like a component, vue-router also provides props. Consider the new router code snippet below.

1 // src/router.ts
2 {
3       path: '/products/edit/:id', // Here id after :, is considered as a prop
4       name: 'edit-product',
5       component: () => import('./components/product/EditProduct.vue'),
6       props: true // needs to enable props to sent to components.
7 }

Here the we need to make sure the name id in router.ts should match the prop id in EditProduct.vue. Now our EditProduct.vue at /products/edit/1 should look as follows, and changing a value and pressing submit should save the changes to database.

edit-product
edit-product

In next chapter we will write a few Vue tests using Jest.

Writing tests with Jest

Testing Our Components

We will be using jest and Vue Test Utils to test our .vue components. Since we are just starting and since the amount of logic is minimal, it a good time to write some tests. Since we are using vue-cli all the configurations are already done for us. Vue-cli even provides us with a simple test case for both unit and e2e testing, in our project setup. In this chapter, we will concentrate on unit tests. So lets get started.

Vue Test Utils is the official Vue unit testing library. Vue Test Utils mounts a Vue component, mocks necessary inputs and returns a Wrapper for the component. Wrapper as docs put it is an object that contains a mounted component or vnode and methods to test the component or vnode. It provides many useful helper functions for making testing easier.

Lets look at our example.spec.js test in tests/unit directory. If there is a .spec or .test name in filename jest automatically detects it and runs the tests in there.

 1 // shallowMount only renders the chosen component and
 2 // avoid rendering its child components.
 3 import { shallowMount } from '@vue/test-utils';
 4 import HelloWorld from '@/components/HelloWorld.vue';
 5 
 6 // describe can group a suite of tests under a name (here it is HelloWorld.vue).
 7 // The second argument is the function which is our test case.
 8 describe('HelloWorld.vue', () => {
 9   it('renders props.msg when passed', () => {
10     const msg = 'new message';
11     const wrapper = shallowMount(HelloWorld, {
12       propsData: { msg },
13     });
14     // expect is used to assert that our values match.
15     // wrapper.text() return text content of wrapper.
16     expect(wrapper.text()).toMatch(msg);
17   });
18 });

We can run the unit tests using

1 npm run test:unit

Now we got a basic idea on how to write a test case, lets write a simple one for our AddProduct.vue, which tests if our input fields are sent correctly when we click the submit button. The implementation is as given below.

 1 import 'jest';
 2 import { mount } from '@vue/test-utils';
 3 import AddProduct from '@/components/product/AddProduct.vue';
 4 import { Product } from '@/types/types';
 5 import products from '@/store/modules/products';
 6 
 7 // We mock the whole store/modules/products module, with jest.
 8 jest.mock('@/store/modules/products', () => ({
 9   service: {
10     // jest.fn creates mock function which replaces actual implementation
11     // of a function. It captures all calls to function with arguments and more.
12     createProduct: jest.fn(() => async (p: Product) => {
13     }),
14     getEmpty: () => {
15       return {};
16     },
17   },
18 }));
19 
20 describe('AddProduct.vue', () => {
21   test('Checks if the product is sent correctly when clicking submit button',
22     async () => {
23     // mount, mounts the component AddProduct in isolation and
24     // returns a wrapper with helper functions.
25     const wrapper = mount(AddProduct, {
26       mocks: {
27         // Here we pass a mock for global function $t.
28         // $t is the translation function from vue-i18n
29         $t: () => { },
30       },
31     });
32     // Finds the element with id productName
33     const inputV = wrapper.find('#productName');
34     // We manually set the value of input field to testNamer
35     inputV.setValue('testNamer');
36     // And we trigger a click event to check whether required
37     // functions are getting called.
38     wrapper.find('[type=\'submit\']').trigger('click');
39 
40     // We need to cast to HTMLInputElement, because
41     // Typescript by default provides a generic HTMLElement,
42     // which lacks some functions from an input field.
43     const t = inputV.element as HTMLInputElement;
44     // Check if the value we had set before clicking submit
45     // is sent to createProduct function.
46     expect(products.service.createProduct).toBeCalledWith({ name: 'testNamer' });
47   });
48 });

Adding Notifications

Now we will add notifications when Products are created or updated and write a few tests for those.

 1 <template>
 2   <div class="container">
 3     <div v-if="this.showNotification" class="notification is-primary">
 4       {{$t('productCreated.label')}}
 5     </div>
 6     <form>
 7       <ProductView :currentProduct="this.currentProduct">
 8         <input
 9           class="button is-black"
10           value="Send"
11           type="submit"
12           v-on:click.prevent="onSubmit"
13         />
14       </ProductView>
15     </form>
16   </div>
17 </template>
18 
19 <script lang="ts">
20   import Vue from "vue";
21   import { Product } from "@/types/types.ts";
22   import ProductView from "@/components/product/ProductView.vue";
23   import { Component, Prop } from "vue-property-decorator";
24   import products from "@/store/modules/products";
25   import { setTimeout } from "timers";
26 
27   @Component({
28     components: {
29       ProductView,
30     },
31   })
32   export default class AddProduct extends Vue {
33     private currentProduct: Product = products.service.getEmpty();
34     private showNotification = false;
35 
36     public async onSubmit() {
37       const response = await products.service.createProduct(
38         this.currentProduct
39       );
40 
41       // If status is 201, which stands for content created we show a notification
42       if (response.status == 201) {
43         this.showNotification = true;
44         // Hide notification after 3 seconds
45         setTimeout(() => {
46           this.showNotification = false;
47         }, 3000);
48       }
49     }
50   }
51 </script>
52 
53 <i18n>
54   { "de": { "productCreated": { "label": "Produkt erstellt" } }, "en": {
55   "productCreated": { "label": "Product created" } } }
56 </i18n>

Here we just added a few translations and added a new showNotification variable, which keeps tracks of whether we should display a notification. We also turn off the notification after 3 seconds using setTimeout function. As logic doesn’t require more explanation, lets write a test which checks if notification is displayed when showNotification is true.

 1 test("Check if product created notification is shown when showNotification is true",
 2   async () => {
 3   // mount, mounts the component AddProduct in isolation and
 4   // returns a wrapper with helper functions.
 5   const wrapper = mount(AddProduct, {
 6     mocks: {
 7       // Here we pass a mock for global function $t.
 8       // $t is the translation function from vue-i18n
 9       $t: () => {},
10     },
11   });
12   // We set showNotification to true and wait for Vue to update
13   // DOM by waiting for vm.nextTick
14   wrapper.vm.$data.showNotification = true;
15   await wrapper.vm.$nextTick();
16   // Search for element with class=notification
17   const t = wrapper.find(".notification");
18   // Assert the element is visible.
19   expect(t.isVisible()).toBe(true);
20 });

Thats all for this chapter. In next chapter, we will start implementing our customer management section.

Build Customer Management Features

In this chapter, we will implement the customer management feature. As you can see the functionality provided by product management is very similar to that should be provided by customer management feature. The data to be stored is different, but the provided operations are similar like creation, editing etc. For the same reason, we can simply copy the components/product folder and rename it as components/customer. Rename all occurrences of Product with Customer. So our directory structure looks like below.

1 customer
2 ├── AddCustomer.vue
3 ├── CustomerList.vue
4 ├── CustomerView.vue
5 └── EditCustomer.vue

Modeling Customer in Typescript

types/types.ts file contains our Customer structure, we generated from the database.

1 export interface Customer {
2   updated_at: Date;
3   pincode: string;
4   phone: string;
5   name: string;
6   inserted_at: Date;
7   id: number;
8   details: object;
9 }

We can provide an implementation for Customer interface just like what we did for Product. Here we use the Mapped Types in Typescript for cleaner constructor. Mapped Types allows us to create new types based on old types. For example the Partial<T> we use below can be defined as

1 // Makes all the fields in T optional with ?
2 type Partial<T> = {
3   [P in keyof T]?: T[P];
4 };

We can also define other types like Readonly as below.

1 type Readonly<T> = {
2   readonly // Make all fields in T, readonly
3   [P in keyof T]: T[P];
4 };

Implementing Customer

Our implementation of Customer can be as follows.

 1 class CustomerImpl implements Customer {
 2   // Initialize variable with default variables.
 3   inserted_at: Date = new Date();
 4   updated_at: Date = new Date();
 5   pincode: string = '';
 6   phone: string = '';
 7   name: string = '';
 8   id: number = 0;
 9   details: object = {};
10 
11   /* Mapped Types in Typescript.
12    * Partial<T> makes all fields of T as optional.
13    * This allows us to just update the values passed in
14    * constructor(which itself is optional with a ?) and assigns to our object.
15    * Then we can initialize like new CustomerImpl({name: "MyName"})
16    */
17   public constructor(customer?: Partial<Customer>) {
18     Object.assign(this, customer);
19   }
20 }
21 
22 // Factory methods
23 export function getEmptyProduct() {
24   return new ProductImpl();
25 }
26 
27 export function getEmptyCustomer() {
28   return new CustomerImpl();
29 }

Talking with Phoenix Backend

In order to communicate with our Phoenix backend, we need to create a customerService like productService. For this we copy services/productService.ts as customerService.ts and simply replace product with customer and Product with Customer. This is possible as we have similar functionality and uniform interface to our backend server. Our resulting customerService is as follows.

 1 import axios, { AxiosPromise } from "axios";
 2 import { Customer, getEmptyCustomer } from "@/types/types";
 3 
 4 export class CustomerService {
 5   private endpoint: string;
 6   private entity: string;
 7 
 8   constructor(endpoint: string, entity: string) {
 9     this.endpoint = endpoint;
10     this.entity = entity;
11   }
12 
13   public getAllRequest(): AxiosPromise<{ customers: Customer[] }> {
14     const response = axios.get(`${this.endpoint}${this.entity}`);
15     return response;
16   }
17 
18   public createCustomer(data: Customer): AxiosPromise<{ customer: Customer }> {
19     return axios.post(`${this.endpoint}${this.entity}`, { customer: data });
20   }
21 
22   public updateCustomer(
23     identifier: number,
24     data: Customer
25   ): AxiosPromise<{ customer: Customer }> {
26     return axios.put(`${this.endpoint}${this.entity}/${identifier}`, {
27       customer: data,
28     });
29   }
30 
31   public getCustomer(identifier: number): AxiosPromise<{ customer: Customer }> {
32     return axios.get(`${this.endpoint}${this.entity}/${identifier}`);
33   }
34 
35   public deleteCustomer(identifier: number): AxiosPromise<any> {
36     return axios.delete(`${this.endpoint}${this.entity}/${identifier}`);
37   }
38 
39   public getEmpty(): Customer {
40     return getEmptyCustomer();
41   }
42 }

In order to persist the customer data, we need to define the Vuex module. Just like customerService it is very similar to store/modules/products.ts. So we follow the same procedure as above. Copy the products.ts as customers.ts and replace all occurrences of product with customer and Product with Customer and we are ready to go.

Building the Customer View

In order to display the customer details, we need the CustomerView.vue. We can see it is similar to our Productview.vue. The implementation is as follows.

 1 <template>
 2   <div>
 3     <div class="field">
 4       <div class="control">
 5         <label class="label" for="customerName"
 6           >{{ $t('customerName.label') }}</label
 7         >
 8         <!-- We bind currentCustomer.name to this input field -->
 9         <input
10           class="input"
11           id="customerName"
12           type="text"
13           v-model="currentCustomer.name"
14         />
15       </div>
16     </div>
17 
18     <div class="field">
19       <div class="control">
20         <label for="customerPincode" class="label"
21           >{{ $t('customerPincode.label') }}</label
22         >
23         <input
24           class="input"
25           id="customerPincode"
26           type="text"
27           v-model="currentCustomer.pincode"
28         />
29       </div>
30     </div>
31 
32     <div class="field">
33       <div class="control">
34         <label class="label" for="customerPhone"
35           >{{ $t('customerPhone.label') }}</label
36         >
37         <input
38           class="input"
39           id="customerPhone"
40           type="text"
41           v-model="currentCustomer.phone"
42         />
43       </div>
44     </div>
45 
46     <div class="field">
47       <div class="control">
48         <slot></slot>
49       </div>
50     </div>
51   </div>
52 </template>
53 
54 <script lang="ts">
55   import { Customer } from "@/types/types.ts";
56   import { Component, Prop } from "vue-property-decorator";
57   import Vue from "vue";
58 
59   @Component({
60     components: {},
61   })
62   export default class CustomerView extends Vue {
63     // Prop are data values that are passed from parent component to child component
64     @Prop()
65     public currentCustomer!: Customer;
66   }
67 </script>
68 
69 <style lang="sass" scoped></style>
70 
71 <i18n>
72   { "de": { "customerName": { "label": "Kunde Name" }, "customerPincode": {
73   "label": "Kunde postleitzahl" }, "customerPhone": { "label": "Kunde Stock" }
74   }, "en": { "customerName": { "label": "Customer Name" }, "customerPincode": {
75   "label": "Customer Pincode" }, "customerPhone": { "label": "Customer Phone" }
76   } }
77 </i18n>

Now implementing CustomerList.vue and AddCustomer.vue is very straightforward as both of them simply use CustomerView.vue component. To implement CustomerList simply replace Product with Customer and product with customer. Same is the case for AddCustomer component too.

Now the only thing left is to add the customer management functionality to our src/router.ts, so that we can actually use it. In router we add the following code and we are ready for action.

 1     {
 2       path: '/customers',
 3       name: 'customers',
 4       component: () => import('./components/customer/CustomerList.vue'),
 5     },
 6     {
 7       path: '/customers/add',
 8       name: 'add-customer',
 9       component: () => import('./components/customer/AddCustomer.vue'),
10     },
11     {
12       path: '/customers/edit/:id',
13       name: 'edit-customer',
14       component: () => import('./components/customer/EditCustomer.vue'),
15       props: true,
16     },

Now go to localhost:8080/customers/add and we can add customers, just like products.

add customer
add customer

Testing Customer Functionity using Jest

Now for the test cases, just copy test/unit/product and rename to test/unit/customer. Then replace all occurrences of product with customer and Product and Customer. The resulting test/unit/customer/AddCustomer.spec.ts will be as follows.

 1 import "jest";
 2 import { mount } from "@vue/test-utils";
 3 import AddProduct from "@/components/product/AddProduct.vue";
 4 import { Product } from "@/types/types";
 5 import products from "@/store/modules/products";
 6 import { doesNotReject } from "assert";
 7 
 8 // We mock the whole store/modules/products module, with jest.
 9 jest.mock("@/store/modules/products", () => ({
10   service: {
11     // jest.fn creates a mock function which replaces actual implementation
12     // of a function. It captures all calls to a function with arguments and more.
13     createProduct: jest.fn(() => async (p: Product) => {
14       return {};
15     }),
16     getEmpty: () => {
17       return {};
18     },
19   },
20 }));
21 
22 describe("AddProduct.vue", () => {
23   test("Checks if the product is sent correctly when clicking submit button",
24     async () => {
25     // mount, mounts the component AddProduct in isolation and
26     // returns a wrapper with helper functions.
27     const wrapper = mount(AddProduct, {
28       mocks: {
29         // Here we pass a mock for global function $t.
30         // $t is the translation function from vue-i18n
31         $t: () => {},
32       },
33     });
34     // Finds the element with id productName
35     const inputV = wrapper.find("#productName");
36     // We manually set the value of input field to testNamer
37     inputV.setValue("testNamer");
38     // And we trigger a click event to see if required functions are getting called.
39     wrapper.find("[type='submit']").trigger("click");
40 
41     // We need to cast to HTMLInputElement, because Typescript by default
42     // provides a generic HTMLElement, which lacks some function from an input field
43     const t = inputV.element as HTMLInputElement;
44     // Check if the value we had set before clicking submit
45     // is sent to createProduct function.
46     expect(products.service.createProduct).toBeCalledWith({
47       name: "testNamer",
48     });
49   });
50 
51   test("Check if product created notification is seen when showNotification is set",
52     async () => {
53     // mount, mounts the component AddProduct in isolation and
54     // returns a wrapper with helper functions.
55     const wrapper = mount(AddProduct, {
56       mocks: {
57         // Here we pass a mock for global function $t.
58         // $t is the translation function from vue-i18n
59         $t: () => {},
60       },
61     });
62     // We set showNotification to true and wait for
63     // Vue to update DOM by waiting for vm.nextTick
64     wrapper.vm.$data.showNotification = true;
65     await wrapper.vm.$nextTick();
66     // Search for element with class=notification
67     const t = wrapper.find(".notification");
68     // Assert the element is visible.
69     expect(t.isVisible()).toBe(true);
70   });
71 });

As you can see by now, we got a lot of duplicate code. These can be easily fixed by refactoring which we will do in the next chapter.

Refactoring Our Frontend

In last chapter, we added customer management functionality. As you’ve already noticed, we just copied and pasted most of the code for customer management from product management functionality. In this chapter, we will use Typescript generics to reduce the code duplication. We also will introduce a uniform interface for different models.

Introducing a Base Service

Let’s start with introducing a base class for src/services. We will move all generic functions to baseService.ts.

 1 import axios, { AxiosPromise } from "axios";
 2 
 3 export abstract class BaseService<T> {
 4   /*
 5    * endpoint is the actual endpoint like http://localhost:8080/api/v1
 6    * entity is the identifier to find the entity we target.
 7    * For eg: http://localhost:8080/{endpoint} requestPrefix is used when we send
 8    * data for creation and updating like {${requestPrefix}: data}
 9    */
10   private endpoint: string;
11   private entity: string;
12   private requestPrefix: string;
13 
14   constructor(endpoint: string, entity: string, requestPrefix: string) {
15     this.endpoint = endpoint;
16     this.entity = entity;
17     this.requestPrefix = requestPrefix;
18   }
19 
20   public getAllRequest(): AxiosPromise<{ data: T[] }> {
21     const response = axios.get(`${this.endpoint}${this.entity}`);
22     return response;
23   }
24 
25   public create(data: T): AxiosPromise<{ data: T }> {
26     const request: { [key: string]: T } = {};
27     request[this.requestPrefix] = data;
28     return axios.post(`${this.endpoint}${this.entity}`, request);
29   }
30 
31   public update(identifier: number, data: T): AxiosPromise<{ data: T }> {
32     const request: { [key: string]: T } = {};
33     request[this.requestPrefix] = data;
34     return axios.put(`${this.endpoint}${this.entity}/${identifier}`, request);
35   }
36 
37   public get(identifier: number): AxiosPromise<{ data: T }> {
38     return axios.get(`${this.endpoint}${this.entity}/${identifier}`);
39   }
40 
41   public delete(identifier: number): AxiosPromise<any> {
42     return axios.delete(`${this.endpoint}${this.entity}/${identifier}`);
43   }
44 
45   public abstract getEmpty(): T;
46 }

As you have notice rather than having a function called updateProduct or updateCustomer, in our baseService, we have a function named update. Also the return type is now always AxiosPromise<{‘data’ :T}>, rather than AxiosPromise<{‘product’ :Product}> or AxiosPromise<{‘customer’ :Customer}>. This provides a uniform interface for all models.

Refactoring Services to use Base Service

Now let’s rewrite our productService and customerService extending baseService.

 1 // productService.ts
 2 import { Product, getEmptyProduct } from "@/types/types";
 3 import { BaseService } from "./baseService";
 4 
 5 export class ProductService extends BaseService<Product> {
 6   // constructors should call parent class with super()
 7   constructor(endpoint: string, entity: string, requestPrefix: string) {
 8     super(endpoint, entity, requestPrefix);
 9   }
10 
11   public getEmpty(): Product {
12     return getEmptyProduct();
13   }
14 }
 1 // customerService.ts
 2 import { Customer, getEmptyCustomer } from "@/types/types";
 3 import { BaseService } from "./baseService";
 4 
 5 export class CustomerService extends BaseService<Customer> {
 6   // constructors should call parent class with super()
 7   constructor(endpoint: string, entity: string, requestPrefix: string) {
 8     super(endpoint, entity, requestPrefix);
 9   }
10 
11   public getEmpty(): Customer {
12     return getEmptyCustomer();
13   }
14 }

As you have noticed we have significantly removed duplicate code. Now simply replace all createCustomer or updateProduct function calls with just create or update function calls. This will change a few files. Since the changes are rather trivial, you can look it up in the bitbucket repo. There are also a few changes in the structure of data returned by our service. Previously our responses had the structure response.data.product or response.data.customer. Since we changed the return type in baseService, to AxiosPromise<{‘data’: T}>, now all responses have the same structure response.data.data. Some of you will notice, that we previously changed our backend server to provide product or customer rather than data, due to personal preferences :D. Now we change it back :).

Now our ms_web/views/product_view.ex will be as follows

 1 defmodule MsWeb.ProductView do
 2   use MsWeb, :view
 3   alias MsWeb.ProductView
 4 
 5   def render("index.json", %{products: products}) do
 6     %{data: render_many(products, ProductView, "product.json")}
 7   end
 8 
 9   def render("show.json", %{product: product}) do
10     %{data: render_one(product, ProductView, "product.json")}
11   end
12 
13   def render("product.json", %{product: product}) do
14     %{id: product.id,
15       price: product.price,
16       stock: product.stock,
17       name: product.name,
18       tax: product.tax}
19   end
20 end

Similarly our ms_web/views/customer_view.ex will become

 1 defmodule MsWeb.CustomerView do
 2   use MsWeb, :view
 3   alias MsWeb.CustomerView
 4 
 5   def render("index.json", %{customers: customers}) do
 6     %{data: render_many(customers, CustomerView, "customer.json")}
 7   end
 8 
 9   def render("show.json", %{customer: customer}) do
10     %{data: render_one(customer, CustomerView, "customer.json")}
11   end
12 
13   def render("customer.json", %{customer: customer}) do
14     %{id: customer.id,
15       name: customer.name,
16       phone: customer.phone,
17       pincode: customer.pincode,
18       details: customer.details}
19   end
20 end

Now running our tests, all tests should pass. In next chapter, we will refactor our Phoenix codebase and fix tests.

Refactoring and adding tests for Backend

In this chapter, we will refactor our Phoenix codebase and fix tests. So our step would be to adopt a consistent naming for database tables and column names. Currently we have camel case and snake case for our table column names. We will adopt the snake case style for our column names. The first step to achieve this would be to create a migration and then rename the required columns in that migration.

1 mix ecto.gen.migration rename_fields_snake_case

This will create a file in the priv/repo/migrations/ folder. Lets make the following changes there.

1 defmodule Ms.Repo.Migrations.RenameFieldsSnakeCase do
2   use Ecto.Migration
3 
4   def change do
5     rename table("orders"), :creationDate, to: :creation_date
6     rename table("order_items"), :unitPrice, to: :unit_price
7     rename table("deliveries"), :orderitem, to: :order_item
8   end
9 end

The change function will be executed when we apply the migration. The changes are self-explanatory. Now we need to rename all occurrences of unitPrices to unit_price and so on in all files. This is a simple search and replace operation. This results in changes in schema files inside ms/ directory. For example the ms/order_management/order_item.ex file becomes

 1 defmodule Ms.OrderManagement.OrderItem do
 2   use Ecto.Schema
 3   import Ecto.Changeset
 4 
 5   schema "order_items" do
 6     field :amount, :integer
 7     field :unit_price, :float
 8     field :product, :id
 9     field :order, :id
10 
11     timestamps()
12   end
13 
14   @doc false
15   def changeset(order_item, attrs) do
16     order_item
17     |> cast(attrs, [:amount, :unit_price])
18     |> validate_required([:amount, :unit_price])
19   end
20 end

This will also change some tests. But by running tests using,

1 MIX_ENV=test mix test

we can confirm that all tests are passing. MIX_ENV is an environment telling mix about the running context. For example to drop the test database from ecto, we can use

1 MIX_ENV=test mix ecto.drop

If we set MIX_ENV=prod, it will drop the production database.

Now lets fix another inconsistency in our naming conventions. All management modules like customer_management, order_management end with an _management, except inventory. In order to ensure consistency we will rename inventory to inventory_management. This is also pretty simple by replacing all occurrences of Inventory with InventoryManagement. Then rename inventory folder to inventory_management and we are done. Just like in previous case, it will also change some tests. By running tests again, we can confirm that things are working correctly.

In next chapter, we will add order handling functionality.

Adding Order Management

Before we get started with adding order management, let’s make some server-side changes to enforce foreign key constraints. This will make the database querying easier with respect to foreign keys and also provides us with helper function when working with ecto.

Renaming Fields in Ecto Schema

To start with, lets write a migration to rename all fields in schemas to end with _id if it refers to a foreign key. This will make all foreign keys explicit and also will be useful in future, when we are writing ecto queries.

1 mix ecto.gen.migration rename_fields_for_foreign_keys

And fill in repo/migrations/rename_fields_for_foreign_keys.exs with following content.

 1 defmodule Ms.Repo.Migrations.RenameFieldsForForeignKeys do
 2   use Ecto.Migration
 3 
 4   def change do
 5     rename table("order_items"), :order, to: :order_id
 6     rename table("order_items"), :product, to: :product_id
 7     rename table("orders"), :customer, to: :customer_id
 8     rename table("deliveries"), :order_item, to: :order_item_id
 9   end
10 end

Once we have changed the database tables, the schema files should be changed to reflect it.

Let’s take a look at the changes in lib/ms/customer_management/customer.ex file.

 1 defmodule Ms.CustomerManagement.Customer do
 2   use Ecto.Schema
 3   import Ecto.Changeset
 4 
 5   schema "customers" do
 6     field :details, :map
 7     field :name, :string
 8     field :phone, :string
 9     field :pincode, :string
10     # has_many tells that there can be multiple orders from a customer
11     # This can be used to preload all orders from a customer
12     has_many :orders, Ms.OrderManagement.Order
13 
14     timestamps()
15   end
16 
17   @doc false
18   def changeset(customer, attrs) do
19     customer
20     |> cast(attrs, [:name, :phone, :pincode, :details])
21     |> validate_required([:name, :phone, :pincode, :details])
22   end
23 end

As you can see in comments we added has_many. It is a macro, which does not do anything to database, but allows us to access the corresponding Ms.OrderManagement.Order via the foreign key. It also allows us to easily prefetch it, without writing any SQL.

Similarly we change lib/ms/delivery_management/delivery.ex as follows

 1 defmodule Ms.DeliveryManagement.Delivery do
 2   use Ecto.Schema
 3   import Ecto.Changeset
 4 
 5   schema "deliveries" do
 6     field :address, :map
 7     field :details, :map
 8     field :fare, :float
 9     # If a name ends with _id ecto consider it to be an id or a foreign key id.
10     field :order_item_id, :id
11     # belongs_to says that this deliveries schema has a
12     # foreign key reference to customers schema.
13     # Just like has_many, it too offers more flexibility, when querying using ecto
14     belongs_to :customer, Ms.CustomerManagement.Customer
15 
16     timestamps()
17   end
18 
19   @doc false
20   def changeset(delivery, attrs) do
21     delivery
22     |> cast(attrs, [:fare, :address, :details])
23     |> validate_required([:fare, :address, :details])
24   end
25 end

belongs_to adds customer_id foreign key to our schema and also allows fetching of Ms.CustomerManagement.Customer corresponding to the customer_id key. We do the same for lib/ms/inventory_management schemas too. If you find these confusing look at the excellent tutorial at Elixir School about ecto and associations.

Lets run our migrations

1 mix ecto.migrate

Now that we are done with migrations, let add support for order items for an order. Lets start with adding some constraints on lib/ms/order_management/order_item.ex. In order for an order_item to be added to database, it should be part of an order. We can enforce it using foreign_key_constraint.

Let’s consider our code.

 1 defmodule Ms.OrderManagement.OrderItem do
 2   use Ecto.Schema
 3   import Ecto.Changeset
 4 
 5   schema "order_items" do
 6     field :amount, :integer
 7     field :unit_price, :float
 8     belongs_to :product, Ms.InventoryManagement.Product
 9     belongs_to :order, Ms.OrderManagement.Order
10 
11     timestamps()
12   end
13 
14   @doc false
15   def changeset(order_item, attrs) do
16     order_item
17     |> cast(attrs, [:amount, :unit_price, :order_id])
18     |> validate_required([:amount, :unit_price, :order_id])
19     |> foreign_key_constraint(:order)
20   end
21 end

Here foreign_key_constraint(:order) means that the :order_id of order_items schema, should already exist in orders table. Otherwise the changeset will be invalid. This makes sure that, we don’t accidentally insert any invalid data.

Modeling JSON representation for Order

Now that we have implemented our order schema with order_items, one question remains. When we ask for an order, should it automatically also provide all associated order_items ?. In our case, I would say yes. Almost every time, we fetch our order, we will also need order_items. So why not always preload order_items along with an order. In order to render order_items with order, we will need to provide a serializer for order_item. For this we change our ms/order_management/order_item.ex as follows.

 1 defmodule Ms.OrderManagement.OrderItem do
 2   use Ecto.Schema
 3   import Ecto.Changeset
 4 
 5   # Automatically constructs a JSON with amount, id, unit_price, order_id
 6   # when we render an order_item.
 7   @derive {Jason.Encoder, only: [:amount, :id, :unit_price, :order_id]}
 8   schema "order_items" do
 9     field :amount, :integer
10     field :unit_price, :float
11     belongs_to :product, Ms.InventoryManagement.Product
12     belongs_to :order, Ms.OrderManagement.Order
13 
14     timestamps()
15   end
16 
17   @doc false
18   def changeset(order_item, attrs) do
19     order_item
20     |> cast(attrs, [:amount, :unit_price, :order_id])
21     |> validate_required([:amount, :unit_price, :order_id])
22     |> foreign_key_constraint(:order)
23   end
24 end

As per the comment @derive uses the Jason encoder to automatically provide us with a JSON representation for order_item. Now the only step left is to always preload all order requests with order_items field. This can be achieved by updating the lib/ms/order_management.ex code as follows.

 1   @doc """
 2   Gets a single order.
 3 
 4   Raises `Ecto.NoResultsError` if the Order does not exist.
 5 
 6   ## Examples
 7 
 8       iex> get_order!(123)
 9       %Order{}
10 
11       iex> get_order!(456)
12       ** (Ecto.NoResultsError)
13 
14   """
15   def get_order!(id), do: Repo.get!(Order, id) |> Repo.preload(:order_items)
16 
17   @doc """
18   Creates a order.
19 
20   ## Examples
21 
22       iex> create_order(%{field: value})
23       {:ok, %Order{}}
24 
25       iex> create_order(%{field: bad_value})
26       {:error, %Ecto.Changeset{}}
27 
28   """
29   def create_order(attrs \\ %{}) do
30     response = %Order{}
31     |> Order.changeset(attrs)
32     |> Repo.insert()
33 
34     case response do
35       {:ok, order} ->
36         Enum.map(attrs["order_items"],
37           fn oi -> create_order_item(Map.merge(oi, %{"order_id" => order.id})) end
38         )
39         {:ok, order |> Repo.preload(:order_items) }
40 
41       {:error, e} -> {:error, e}
42     end
43   end

For both get_order and create_order, we preload order_items using Repo.preload(:order_items). Let’s run our tests to see if everything went right.

1 mix test

Fixing Failing Tests

Unfortunately some of our tests fail. Most of them fail because, when testing order_items, we don’t provide a valid order_id. It can be easily fixed, by first creating an order and then passing the order_id from the created order into the order_item request. Now some other tests fails due to mismatch in struct structure in Elixir. It can fixed by replacing all :atom keys in structures to string. For example, we need to change from %{name: “Name”} to %{“name” ⇒ “Name”}. This is required because in Elixir :name is not the same as “name”.

Tests also expose that we have a bug in handling cascading deletes. Our current migration in repo/migrations/20190613185803_create_order_items.exs works as follows.

 1 defmodule Ms.Repo.Migrations.CreateOrderItems do
 2   use Ecto.Migration
 3 
 4   def change do
 5     create table(:order_items) do
 6       # When a product is deleted, we do nothing. This is wrong as
 7       # the product we need to sell doesn't exist. We should replace
 8       # it to cascading delete.
 9       add :product, references(:products, on_delete: :nothing)
10       add :order, references(:orders, on_delete: :nothing)
11     end
12   end
13 end

To allow deleting of products, we need to change on_delete: :nothing to on_delete: :delete_all. This deletes all order_items when a product is deleted.

Note: In some cases, it is required to preserve all order history. But as you will see later, this can be easily fixed.

So lets create a new migration to fix this issue.

1 mix ecto.gen.migration change_constraints_on_order_items

And add the following code

 1 defmodule Ms.Repo.Migrations.ChangeConstraintsOnOrderItems do
 2   use Ecto.Migration
 3 
 4   def change do
 5     drop constraint("order_items", "order_items_product_fkey")
 6     drop constraint("order_items", "order_items_order_fkey")
 7 
 8     alter table(:order_items) do
 9       modify(:product_id, references(:products, on_delete: :delete_all))
10       modify(:order_id, references(:orders, on_delete: :delete_all))
11     end
12   end
13 end

Run our migration and then the tests.

1 mix ecto.migrate
2 mix test

Now all our tests are green again :D. So now that we have completed the whole backend requirements for implementing orders, we will start with implementing the UI for orders.

Building Order Views

Before we start implementing UI for order management, we need to change the unitPrice and creationDate to unit_price and creation_date in client side too. This is pretty easy as all our types are defined in src/types/types.ts.

The changed code is as follows.

 1 export interface OrderItem {
 2   updated_at: Date;
 3   // Changed from unitPrice
 4   unit_price: number;
 5   product_id: number;
 6   order_id: number;
 7   inserted_at: Date;
 8   id: number;
 9   amount: number;
10 }
11 export interface Order {
12   updated_at: Date;
13   message: string;
14   inserted_at: Date;
15   id: number;
16   details: object;
17   customer_id: number;
18   // creationDate
19   creation_date: Date;
20 }

Now we get started with implementing our order management UI. Our workflow for adding a new order should be as follows.

add order workflow
add order workflow

As a starting step, lets copy required files from product management and rename all occurrences of Product to Order and all product to order. This also applies to files also.

So our component directory structure for order should look as follows

1 order
2 ├── AddOrder.vue
3 ├── EditOrder.vue
4 ├── OrderList.vue
5 └── OrderView.vue

So our src/components/order/OrderView.vue looks as below.

 1 <template>
 2   <div>
 3     <div class="field">
 4       <div class="control">
 5         <label for="orderMessage" class="label">
 6           {{ $t('orderMessage.label') }}
 7         </label>
 8         <input class="input" id="orderMessage" type="text"
 9           v-model="currentOrder.message" />
10       </div>
11     </div>
12 
13     <ul>
14       <li v-for="item in currentOrder.order_items" :key="item.id">{{item}}</li>
15     </ul>
16 
17     <div class="field">
18       <div class="control">
19         <slot></slot>
20       </div>
21     </div>
22   </div>
23 </template>
24 
25 <script lang='ts'>
26 import { Order, Customer, getEmptyCustomer } from '@/types/types.ts';
27 import { Component, Prop } from 'vue-property-decorator';
28 import Vue from 'vue';
29 
30 @Component({
31   components: {},
32 })
33 export default class OrderView extends Vue {
34   // Prop are data values that are passed from parent component to child component.
35   @Prop()
36   public currentOrder!: Order;
37 }
38 </script>
39 
40 <i18n>
41 {
42   "de": {
43     "orderMessage": {
44       "label": "Nachricht bestellen"
45     }
46   },
47   "en": {
48     "orderMessage": {
49       "label": "Order Message"
50     }
51   }
52 }
53 </i18n>
54 
55 <style lang='sass' scoped>
56 </style

As you can see we only display the order message and order items. There is no information about customers. There is also no UI to choose order items. In order to have a good user experience, we will have implement something like an autocomplete and drop down to allow to choose products and customers. If customer doesn’t exist, we should also allow to create a new one. This requires support from backend. For this reason, we will implement this functionality in next chapter. Now we will stick to this placeholder implementation.

Implementing Order Service

Now we will implement the src/services/orderService.ts

 1 import { Order, getEmptyOrder } from "@/types/types";
 2 import { BaseService } from "./baseService";
 3 
 4 export class OrderService extends BaseService<Order> {
 5   // constructors should call parent class with super()
 6   constructor(endpoint: string, entity: string, requestPrefix: string) {
 7     super(endpoint, entity, requestPrefix);
 8   }
 9 
10   public getEmpty(): Order {
11     return getEmptyOrder();
12   }
13 }

So we need to add implementation for getEmptyOrder to our types/types.ts.

 1 class OrderImpl implements Order {
 2   updated_at: Date = new Date();
 3   message: string = "";
 4   inserted_at: Date = new Date();
 5   id: number = -1;
 6   details: object = {};
 7   customer_id: number = -1;
 8   creation_date: Date = new Date();
 9   order_items: OrderItem[] = [];
10 
11   /* Mapped Types in Typescript.
12    * Partial<T> makes all fields of T as optional.
13    * This allows us to just update the values passed in 
14    * constructor(which itself is optional with?) and assigns to our object.
15    * Then we initialize like new CustomerImpl({name: "MyName"})
16    */
17   public constructor(order?: Partial<Order>) {
18     Object.assign(this, order);
19   }
20 }
21 
22 // Factory methods
23 export function getEmptyProduct() {
24   return new ProductImpl();
25 }
26 
27 export function getEmptyCustomer() {
28   return new CustomerImpl();
29 }
30 
31 export function getEmptyOrder() {
32   return new OrderImpl();
33 }

Similarly add src/store/modules/orders.ts.

 1 import {
 2   VuexModule,
 3   Module,
 4   getModule,
 5   Mutation,
 6   Action,
 7 } from "vuex-module-decorators";
 8 import store from "@/store";
 9 import { Order } from "@/types/types";
10 import { OrderService } from "@/services/orderService";
11 
12 /**
13  * When namespaced is set to true, contents of the module is automatically 
14  * namespaced based on name. Vuex allows adding modules to store 
15  * dynamically after store is created, when dynamic flag is set to true.
16  * store is the Vuex store to which we need to insert our module.
17  */
18 @Module({
19   namespaced: true,
20   name: "orders",
21   store,
22   dynamic: true,
23 })
24 class OrderModule extends VuexModule {
25   /**
26    * Class variables automatically become part of the state.
27    * Our module thus will have orders/allOrders and orders/service 
28    * as state variables. The reason we put service inside Vuex is because in this 
29    * case there will be only one OrderService which can be shared
30    * between all components.
31    */
32   public allOrders: Order[] = [];
33   public service: OrderService = new OrderService(
34     "http://0.0.0.0:4000/api/v1/",
35     "orders",
36     "order"
37   );
38 
39   // Action automatically calls setOrders function with arguments
40   // returned by fetchOrders function.
41   @Action({ commit: "setOrders" })
42   public async fetchOrders() {
43     // Calls into service to get all orders
44     const t = await this.service.getAllRequest();
45     return t.data.data;
46   }
47 
48   // modifies our module state, by setting allOrders to p.
49   @Mutation
50   public setOrders(p: Order[]) {
51     this.allOrders = p;
52   }
53 }
54 
55 // Export our module so our components can use it.
56 export default getModule(OrderModule);

As you can see, this file is almost the same like src/store/modules/products.ts. We can just copy products.ts and replace Product with Order and product with order to get this file.

So now the only thing left to do is to add the new order components to router.ts.

 1 //src/router.ts
 2     {
 3       path: '/orders',
 4       name: 'orders',
 5       component: () => import('./components/order/OrderList.vue'),
 6     },
 7     {
 8       path: '/orders/add',
 9       name: 'add-order',
10       component: () => import('./components/order/AddOrder.vue'),
11     },
12     {
13       path: '/orders/edit/:id',
14       name: 'edit-order',
15       component: () => import('./components/order/EditOrder.vue'),
16       props: true,
17     },

Now we should be able to add new order at localhost:8080/orders/add. Similarly we can view all orders at localhost:8080/orders/.

order creation page
order creation page

In next chapter, we will add an autocomplete with search functionality and improve the UI for creating new orders.

Adding new features to Order Management

In last chapter, we added a bare minimum order management functionality, where you can add only an order message. In this chapter, we will add functionality to add order items and a customer to the order. The order items and customer can be chosen with an autocomplete menu. So let’s get started.

Implementing Autocomplete

The autocomplete functionality for products will be based on product name. So if the user enters a part of the product name, we should show the product with its full name. Our current autocomplete will be naive and will find products using a LIKE query in SQL. We will also cache all results using ETS. This will help to reduce the database lookups.

In order to implement the autocomplete functionality, we need to add a new endpoint to our server. Let’s place it under products/search.

Lets add our product search functionality with InventoryManagement.search_product function inside lib/ms/inventory_management.ex.

 1 @doc """
 2 Returns a single Product
 3 """
 4 def search_product(term) do
 5   query =
 6     from p in Product,
 7       select: p,
 8       where: ilike(p.name, ^"%#{term}%")
 9 
10   Repo.all(query)
11 end

Here we use ilike which does a case-insensitive LIKE in SQL. The ^ inside ilike is used to bind to dynamic values. In our case we pass our term argument inside SQL query. Now let’s make this functionality available to our web controllers.

We will pass all required data with GET request as url parameters. It is a better option than passing data with the body of the request. Some firewalls may even strip the body from a GET request.

Inside lib/ms_web/controllers/product_controller.ex, we add the search function to handle our searches.

1 def search(conn, %{"term" => term}) do
2   products = InventoryManagement.search_product(term)
3   render(conn, "index.json", products: products)
4 end

Now we just need to register an endpoint in lib/ms_web/router.ex.

1 scope "/v1" do
2     get "/products/search", ProductController, :search

An automated test case would be a good idea, to test this functionality. This will also be useful when we add the cache. Inside test/ms_web/controllers/product_controller.exs

1 describe "search products" do
2   setup [:create_product]
3 
4   test "searches all products containing string", %{conn: conn} do
5     conn = get(conn, Routes.product_path(conn, :search, %{term: "ome"}))
6     assert [%{"name" => "some name"}] = json_response(conn, 200)["data"]
7   end
8 end

This new test should pass. If you want to test manually, going to http://localhost:4000/api/v1/products/search?term=om, will give us all products whose name has the string om in it.

Caching Results using GenServer

Now it’s a good time to add a cache to our product search. We will use a GenServer to do our caching. Our cache will be run in a separate process and this process will hold our ets tables making our problem more resilient to crashes. Before we add the cache, we got a few basics to cover. These basics will help you understand why Elixir/Erlang is very suited to writing fault-tolerant applications. Since I don’t want to repeat what I’ve already written, please read this and come back.

We will add a our caching GenServer inside lib/ms/cache_management/product.ex file.

 1 defmodule Ms.CacheManagement.Product do
 2   @moduledoc """
 3     Caches our product data, based on substring index
 4   """
 5 
 6   use GenServer
 7 
 8   # Starts a GenServer process running this module
 9   def start_link(_opts) do
10     # This name will be used to send message in GenServer.cast and GenServer.call
11     GenServer.start_link(__MODULE__, %{}, name: Product)
12   end
13 
14   # This function will be executed when a GenServer is spawned and output 
15   # of this function becomes the state of the Genserver.
16   def init(state) do
17     :ets.new(:inventory_cache, [:set, :public, :named_table])
18     {:ok, state}
19   end
20 
21   def delete(key) do
22     # GenServer.cast is asynchronous on client side, while GenServer.call 
23     # is Synchronous(blocking)
24     GenServer.call(Product, {:delete, key})
25   end
26 
27   def clean_cache() do
28     GenServer.call(Product, {:clean})
29   end
30 
31   # Notice that here we don't use GenServer.
32   # If we use GenServer, all reads will end up being a serial operation, 
33   # as GenServer executes serially
34   # ETS allows concurrent reads, so we directly query the ETS and all other 
35   # operations like put and delete goes through GenServer, inturn serializing them.
36   def get(key) do
37     case :ets.lookup(:inventory_cache, key) do
38       [] -> []
39       [{_key, product}] -> product
40     end
41   end
42 
43   # Notice that we use GenServer.call even if we don't take return value.
44   # This is required because GenServer.cast is asynchronous and hence is not strict
45   # If we use cast, we can't guarantee that put will have put the value in ets, 
46   # when it returns.
47   def put(key, value) do
48     # We use cast when we don't care about the result
49     GenServer.call(Product, {:put, key, value})
50   end
51 
52   # All GenServer.cast calls ends up calling handle_cast
53   def handle_call({:delete, key}, _from, state) do
54     :ets.delete(:inventory_cache, key)
55     # Since we don't care about result, we reply with :ok
56     {:reply, :ok, state}
57   end
58 
59   def handle_call({:put, key, data}, _from, state) do
60     :ets.insert(:inventory_cache, {key, data})
61     {:reply, :ok, state}
62   end
63 
64   def handle_call({:clean}, _from, state) do
65     :ets.delete_all_objects(:inventory_cache)
66     {:reply, :ok, state}
67   end
68 
69   # All GenServer.call calls ends up calling handle_call
70   # If we don't have this handling, which collects all messages, we can have 
71   # a memory leak as elixir doesn't throw away unmatched/unhandled messages.
72   # Here using this handler we just throw them away.
73   def handle_call(_msg, _from, state) do
74     {:reply, :ok, state}
75   end
76 end

Now we modify our lib/ms_web/controllers/product_controller.ex to make use of our cache.

 1 defmodule MsWeb.ProductController do
 2   use MsWeb, :controller
 3 
 4   alias Ms.InventoryManagement
 5   alias Ms.InventoryManagement.Product
 6   alias Ms.CacheManagement
 7 
 8   action_fallback MsWeb.FallbackController
 9 
10   def index(conn, _params) do
11     products = InventoryManagement.list_products()
12     render(conn, "index.json", products: products)
13   end
14 
15   def create(conn, %{"product" => product_params}) do
16     with {:ok, %Product{} = product} <- 
17       InventoryManagement.create_product(product_params) do
18       CacheManagement.clean_product_cache()
19 
20       conn
21       |> put_status(:created)
22       |> put_resp_header("location", Routes.product_path(conn, :show, product))
23       |> render("show.json", product: product)
24     end
25   end
26 
27   def show(conn, %{"id" => id}) do
28     product = InventoryManagement.get_product!(id)
29     render(conn, "show.json", product: product)
30   end
31 
32   def update(conn, %{"id" => id, "product" => product_params}) do
33     product = InventoryManagement.get_product!(id)
34 
35     with {:ok, %Product{} = product} <-
36            InventoryManagement.update_product(product, product_params) do
37       CacheManagement.clean_product_cache()
38       render(conn, "show.json", product: product)
39     end
40   end
41 
42   def delete(conn, %{"id" => id}) do
43     product = InventoryManagement.get_product!(id)
44 
45     with {:ok, %Product{}} <- InventoryManagement.delete_product(product) do
46       CacheManagement.clean_product_cache()
47       send_resp(conn, :no_content, "")
48     end
49   end
50 
51   def search(conn, %{"term" => term}) do
52     # First, we try to get products from the cache
53     products = CacheManagement.get_products(term)
54 
55     if products == [] do
56       # If cache is empty, we fetch from database and write to cache
57       products = InventoryManagement.search_product(term)
58       CacheManagement.put_product(term, products)
59     end
60 
61     # Load value again from cache
62     products = CacheManagement.get_products(term)
63     render(conn, "index.json", products: products)
64   end
65 end

Add Cache to Application

Now all that is left to do is the ask our Phoenix application to start our cache with it. To do this add our cache inside the children list inside start in file lib/ms/application.ex. Now our cache is supervised and if it crashes will be rebooted based on strategy defined in our Application.

 1 def start(_type, _args) do
 2   # List all child processes to be supervised
 3   children = [
 4     # Start the Ecto repository
 5     Ms.Repo,
 6     # Start the endpoint when the application starts
 7     MsWeb.Endpoint,
 8     # Starts a worker by calling: Ms.Worker.start_link(arg)
 9     # {Ms.Worker, arg},
10     Ms.CacheManagement.Product
11   ]

All our tests should pass after the addition of cache.

Visualizing GenServer with Observer

For those of you who are wondering if it is possible to visualize our new GenServer, there is a good news. Run the phoenix app inside iex and we should be able to use the observer to see the whole application.

1 iex -S mix phx.server
2 :observer.start
BEAM observer
BEAM observer

Notice the Elixir.Product at the bottom, that is our product cache. It was named based on the name we gave in start_link in ms/cache_management/product.ex file.

In a similar fashion, we can implement our Customer Cache. Please take a look at code to see the actual implementation.

Now that our backend is ready, lets move on to the frontend. Our frontend will be pretty crude in the beginning. We will fix those in coming chapters :D.

As you know, our AddOrder is pretty primitive. In order to make it more user friendly, we need to have an autocomplete functionality in UI. Autocomplete should work for both customer and products.

Building Autocomplete UI Vue Component

The contents of src/components/utils/AutoComplete.vue is as follows.

  1 <template>
  2   <!--
  3     We want to avoid all default event propagations on the input form.
  4     So we use @click.stop @keyup event will be triggered when user 
  5     has finished entering a character.
  6     -->
  7   <div @click.stop>
  8     <input
  9       type="text"
 10       v-model="autoCompleteInput"
 11       class="input"
 12       :placeholder="displayText"
 13       @focus="active=true"
 14       @keyup="updateList"
 15     />
 16     <!-- 
 17       We loop through all suggestions using v-for and put them 
 18       inside a unordered list (ul). The loop will be rendered as
 19       <ul>
 20         <li> Option 1</li>
 21         <li> Option 2</li>
 22       </ul>
 23       -->
 24     <ul name="autocomplete" v-if="active">
 25       <li
 26         :value="l.name"
 27         v-for="l in autoCompleteSuggestions"
 28         v-bind:key="l.id"
 29         @click.prevent="selectionChanged(l)"
 30         class="button is-fullwidth"
 31         style="border-radius: 0px"
 32       >
 33         {{l.name}}
 34       </li>
 35     </ul>
 36   </div>
 37 </template>
 38 
 39 <script lang="ts">
 40   import Vue from "vue";
 41   import { Customer } from "@/types/types.ts";
 42   import { Component, Prop } from "vue-property-decorator";
 43   import { AxiosPromise } from "axios";
 44 
 45   @Component({
 46     components: {},
 47   })
 48   export default class AutoComplete<T extends { name: string }> extends Vue {
 49     /**
 50      * searchFn takes a string and return an AxiosPromise.
 51      * With this function signature we can easy pass currentInput and get 
 52      * possible auto completions from backend easily.
 53      */
 54     @Prop()
 55     public searchFn!: (s: string) => AxiosPromise<{ data: T[] }>;
 56 
 57     /**
 58      * This is our html placeholder, which writes something 
 59      * like enter a customer name/ product name
 60      */
 61     @Prop()
 62     public displayText!: string;
 63 
 64     /**
 65      * Our input variable which is bound to html input form.
 66      * As you can see, we use v-model from vue, which binds autoCompleteInput 
 67      * to html input. v-model provides 2-way data binding such that change 
 68      * in one will be automatically propagated to other.
 69      */
 70 
 71     private autoCompleteInput: string = "";
 72 
 73     /**
 74      * Autocomplete suggestions list will only be shown when active is set to true.
 75      * When the html input box is focused, we set it to true using 
 76      * the @focus event from browser. When a value is chosen, we set it to false.
 77      */
 78     private active: boolean = false;
 79 
 80     /**
 81      * List holding our auto complete suggestions, we got from the server.
 82      * Actually the type T is pretty useless here, as vue internally 
 83      * instantiates the component.
 84      */
 85     private autoCompleteSuggestions: T[] = [];
 86 
 87     public async mounted() {
 88       /* Inside function(), we will have a new this, which points to the 
 89        * function itself. So we store current this to vueThis and access 
 90        * vue instance using vueThis.
 91        */
 92       let vueThis = this;
 93       document.addEventListener("click", function () {
 94         vueThis.active = false;
 95       });
 96       this.autoCompleteSuggestions = [];
 97     }
 98 
 99     /**
100      * Function which connects to backend and asks for auto completions 
101      * based on current html input and updates the html list
102      */
103     public updateList(event: KeyboardEvent): void {
104       let request = this.searchFn(this.autoCompleteInput);
105       let vueThis = this;
106       request.then(
107         (result) => (vueThis.autoCompleteSuggestions = result.data.data)
108       );
109     }
110 
111     /**
112      * Emits event to parent component when an option is chosen from suggestion list
113      */
114     public selectionChanged(l: T) {
115       // user @Emit
116       this.$emit("OptionSelected", l);
117       // To give visual feedback to user, we also set html input to chosen item.
118       this.autoCompleteInput = l.name;
119       this.active = false;
120     }
121   }
122 </script>

Now that we got a generic autocomplete component which can take values based on a search function, let’s build a view to show an Order Item to the user. Before we implement view we need to implement OrderItemImpl class. It can be as follows.

 1 class OrderItemImpl implements OrderItem {
 2   updated_at: Date = new Date();
 3   unit_price: number = -1;
 4   product_id: number = -1;
 5   order_id: number = -1;
 6   inserted_at: Date = new Date();
 7   id: number = -1;
 8   amount: number = 1;
 9 
10   public constructor(orderItem?: Partial<OrderItem>) {
11     Object.assign(this, orderItem);
12   }
13 }
14 
15 export function getEmptyOrderItem() {
16   return new OrderItemImpl();
17 }

Our current backend implementation, doesn’t provide product_id for orderitem. To fix it, we add :product_id to schema present in lib/ms/order_management/order_item.ex file as follows.

1 @derive {Jason.Encoder, only: [:amount, :id, :unit_price, :order_id, :product_id]}

Now we can implement OrderItemView which is as follows contained in file src/components/orderitem/OrderItemView.vue.

 1 <template>
 2   <div v-if="item">
 3     <table>
 4       <tr>
 5         <td>
 6           Product ID {{item.product_id}}
 7         </td>
 8         <td>
 9           Amount {{item.amount}}
10         </td>
11       </tr>
12     </table>
13   </div>
14 </template>
15 
16 <script lang="ts">
17   import { Component, Prop } from "vue-property-decorator";
18   import Vue from "vue";
19   import { OrderItem, Product } from "@/types/types";
20   @Component({
21     components: {},
22   })
23   export default class OrderItemView extends Vue {
24     @Prop()
25     public item!: OrderItem;
26   }
27 </script>

Our component is bare minimum and doesn’t even have a decent UI. But we will fix it later. A good addon for AddOrder.vue would be a button to adjust the quantity of products ordered. So we will add a button to increment the quantity and one to decrement it. We will build it into OrderView.vue as an Order Item ultimately belongs to an order.

 1 <template>
 2   <div>
 3     <div class="field">
 4       <div class="control">
 5         <label for="orderMessage" class="label"
 6           >{{ $t('orderMessage.label') }}</label
 7         >
 8         <input
 9           class="input"
10           id="orderMessage"
11           type="text"
12           v-model="currentOrder.message"
13         />
14       </div>
15     </div>
16 
17     <div>
18       <!--
19         We loop through all order items and add a + and - button.
20         We also bind it to plusClicked and minusClicked functions respectively.
21         We also add a button to delete the product
22         We use font-awesome icons for + and - signs and align it in a row.
23       -->
24       <table>
25         <tr v-for="item in currentOrder.order_items" :key="item.product_id">
26           <td>
27             <OrderItemView :item="item"></OrderItemView>
28           </td>
29           <td v-if="showAmountChanger" @click="plusClicked(item)">
30             <i class="fas fa-plus"></i>
31           </td>
32           <td v-if="showAmountChanger">&nbsp;&nbsp;</td>
33           <td v-if="showAmountChanger" @click="minusClicked(item)">
34             <i class="fas fa-minus"></i>
35           </td>
36           <td v-if="showAmountChanger">&nbsp;&nbsp;</td>
37           <td v-if="showAmountChanger" @click="deleteClicked(item)">
38             <i class="fas fa-times"></i>
39           </td>
40         </tr>
41       </table>
42     </div>
43 
44     <!--
45       Slot enable to replace <slot></slot> with a component.
46       -->
47     <div class="field">
48       <div class="control">
49         <slot></slot>
50       </div>
51     </div>
52   </div>
53 </template>
54 
55 <script lang="ts">
56   import Vue from "vue";
57   import { Order, OrderItem } from "@/types/types.ts";
58   import { Component, Prop, Provide } from "vue-property-decorator";
59   import OrderItemView from "../orderitem/OrderItemView.vue";
60 
61   @Component({
62     components: {
63       OrderItemView,
64     },
65   })
66   export default class OrderView extends Vue {
67     @Prop()
68     public currentOrder!: Order;
69 
70     @Prop({ default: false })
71     public showAmountChanger!: boolean;
72 
73     /**
74      * We signal a plus clicked or minus clicked with
75      * AmountIncremented and AmountDecremented events respectively.
76      */
77     public plusClicked(item: OrderItem) {
78       this.$emit("AmountIncremented", item);
79     }
80 
81     public minusClicked(item: OrderItem) {
82       this.$emit("AmountDecremented", item);
83     }
84 
85     public deleteClicked(item: OrderItem) {
86       this.$emit("OrderItemRemoved", item);
87     }
88   }
89 </script>
90 
91 <i18n>
92   { "de": { "orderMessage": { "label": "Nachricht bestellen" } }, "en": {
93   "orderMessage": { "label": "Order Message" } } }
94 </i18n>
95 
96 <style lang="sass" scoped></style>

Integrating Autocomplete with Orders

As you notice, the changes are minor and lets wire up everything with our AddOrder.vue component. As per our order creation workflow, the user should choose the customer first and then only will have the option to add order items(products). We can model it, by using multiple stages for the order creation. The first stage will be ChooseCustomer and then proceed to ChooseOrderItems and ending with OrderCreated. We use OrderCreationStage union type in Typescript to represent each state. Lets take a look at our code at src/components/order/AddOrder.vue.

The completed src/components/order/AddOrder.vue is as follows.

  1 <template>
  2   <div class="container">
  3     <div v-if="showNotification" class="notification is-primary">
  4       {{$t('orderCreated.label')}}
  5     </div>
  6 
  7     <div>
  8       <!--
  9         Here searchCustomers is a function which takes a string and 
 10         returns a list of customer suggestions. optionSelected event is fired 
 11         by Autocomplete, when an item from list is chosen.
 12       -->
 13       <AutoComplete
 14         :displayText="$t('searchCustomer.label')"
 15         :searchFn="searchCustomers"
 16         @OptionSelected="customerChosen"
 17       ></AutoComplete>
 18     </div>
 19     <!--
 20       Shows the currenly chosen customer
 21     -->
 22     <div>
 23       <CustomerView :currentCustomer="currentCustomer"></CustomerView>
 24     </div>
 25 
 26     <div v-if="customerDetailsComplete(this.currentCustomer)">
 27       <input
 28         class="button is-black"
 29         value="Continue to Orders"
 30         type="submit"
 31         v-on:click.prevent="moveToChooseOrderItems()"
 32       />
 33     </div>
 34 
 35     <div>
 36       <OrderView
 37         :currentOrder="this.currentOrder"
 38         @AmountIncremented="amountIncremented"
 39         @AmountDecremented="amountDecremented"
 40         @OrderItemRemoved="orderItemRemoved"
 41         :showAmountChanger="true"
 42       ></OrderView>
 43     </div>
 44 
 45     <div v-if="state == 'ChooseOrderItems'">
 46       <!--
 47         Here searchProducts is a function which takes a string and returns 
 48         a list of product suggestions. optionSelected event is fired by 
 49         Autocomplete, when an item from list is chosen.
 50       -->
 51       <AutoComplete
 52         :displayText="$t('searchItem.label')"
 53         :searchFn="searchProducts"
 54         @OptionSelected="productChosen"
 55       ></AutoComplete>
 56       <!--
 57         Shows the currently chosen product
 58       -->
 59       <div>
 60         <ProductView :currentProduct="currentProduct"></ProductView>
 61       </div>
 62     </div>
 63 
 64     <input
 65       class="button is-black"
 66       value="Send"
 67       type="submit"
 68       v-on:click.prevent="onSubmit"
 69     />
 70   </div>
 71 </template>
 72 
 73 <script lang="ts">
 74   import Vue from "vue";
 75   import {
 76     Order,
 77     Product,
 78     Customer,
 79     getEmptyOrderItem,
 80     OrderItem,
 81   } from "@/types/types.ts";
 82   import OrderView from "@/components/order/OrderView.vue";
 83   import { Component, Prop } from "vue-property-decorator";
 84   import orders from "@/store/modules/orders";
 85   import products from "@/store/modules/products";
 86   import AutoComplete from "../utils/AutoComplete.vue";
 87   import customers from "@/store/modules/customers";
 88   import CustomerView from "@/components/customer/CustomerView.vue";
 89   import ProductView from "@/components/product/ProductView.vue";
 90 
 91   // You can't define types inside the class
 92   type OrderCreationStage =
 93     | "ChooseCustomer"
 94     | "ChooseOrderItems"
 95     | "OrderCreated";
 96 
 97   @Component({
 98     components: {
 99       OrderView,
100       AutoComplete,
101       CustomerView,
102       ProductView,
103     },
104   })
105   export default class AddOrder extends Vue {
106     private currentOrder: Order = orders.service.getEmpty();
107     private state: OrderCreationStage = "ChooseCustomer";
108     private currentCustomer: Customer = customers.service.getEmpty();
109     private currentProduct: Product = products.service.getEmpty();
110     private showNotification = false;
111 
112     /**
113      * Resets the form.
114      * It is called after an order was successfully created.
115      */
116     public resetAddOrder() {
117       this.currentOrder = orders.service.getEmpty();
118       this.state = "ChooseCustomer";
119       this.currentCustomer = customers.service.getEmpty();
120       this.currentProduct = products.service.getEmpty();
121     }
122 
123     /**
124      * Contacts backend using customers service and get autocompletion suggestions
125      */
126     public searchCustomers(name: string) {
127       return customers.service.search({ term: name });
128     }
129 
130     /**
131      * Contacts backend using products service and get autocompletion suggestions
132      */
133     public searchProducts(name: string) {
134       return products.service.search({ term: name });
135     }
136 
137     public amountIncremented(item: OrderItem) {
138       item.amount = item.amount + 1;
139     }
140 
141     public amountDecremented(item: OrderItem) {
142       item.amount = item.amount - 1;
143     }
144 
145     public orderItemRemoved(item: OrderItem) {
146       this.currentOrder.order_items = this.currentOrder.order_items.filter(
147         (x) => x.product_id !== item.product_id
148       );
149     }
150 
151     /**
152      * Checks if a customer already exists, if it doesn't exist creates 
153      * a new customer and sets it as the currentCustomer.
154      * When a all customer details are provided, we call this function.
155      */
156     public moveToChooseOrderItems() {
157       if (this.currentCustomer.id === 0) {
158         let response = customers.service.create(this.currentCustomer);
159         let vueThis = this;
160         response.then((v) => {
161           vueThis.currentCustomer = v.data.data;
162         });
163       }
164       this.state = "ChooseOrderItems";
165     }
166 
167     /**
168      * Checks if all required customer details are provided
169      */
170     public customerDetailsComplete(customer: Customer) {
171       return (
172         customer.phone !== "" && customer.pincode !== "" && customer.name !== ""
173       );
174     }
175 
176     public async onSubmit() {
177       this.currentOrder.customer_id = this.currentCustomer.id;
178       const response = await orders.service.create(this.currentOrder);
179 
180       // If status is 201, which stands for content created we show a notification
181       if (response.status == 201) {
182         this.resetAddOrder();
183         this.showNotification = true;
184 
185         // Hide notification after 3 seconds
186         setTimeout(() => {
187           this.showNotification = false;
188         }, 3000);
189       }
190     }
191 
192     public customerChosen(item: Customer) {
193       this.currentCustomer = item;
194     }
195 
196     /**
197      * Add the item to list of item in current order.
198      * If item already exists in the order items, we skip the item.
199      * When a product is chosen, we call this function
200      */
201     public productChosen(item: Product) {
202       // We need to convert the Product to an OrderItem to add into order_items
203       let orderItem = getEmptyOrderItem();
204       orderItem.product_id = item.id;
205       orderItem.amount = 1;
206 
207       // Checks if orderItem already exists with the order. If exists,
208       // we skip it again and return.
209       let orderItemExists = this.currentOrder.order_items.filter(
210         (x) => x.product_id === item.id
211       );
212       if (orderItemExists.length !== 0) {
213         return false;
214       }
215 
216       // Update order with new order item
217       this.currentOrder.order_items.push(orderItem);
218       this.currentProduct = item;
219     }
220   }
221 </script>
222 
223 <i18n>
224   { "de": { "orderCreated": { "label": "Auftrag erstellt" }, "searchCustomer": {
225   "label": "Kunden suchen" }, "searchItem": { "label": "Nach Artikeln suchen" }
226   }, "en": { "orderCreated": { "label": "Order created" }, "searchCustomer": {
227   "label": "Search for Customers" }, "searchItem": { "label": "Search for items"
228   } } }
229 </i18n>

Adding Search to Base Service

Now the only thing left is to add search functionality to src/services/baseService.ts.

1 public search(searchTerm: {'term': string}): AxiosPromise<{'data': T[]}> {
2   const response = 
3     axios.get(`${this.endpoint}${this.entity}/search?term=${searchTerm.term}`);
4   return response;

Now our order creation page should look as follows.

Order creation page
Order creation page

The src/components/order/EditOrder.vue is very similar to AddOrder.vue, with the only difference that we are unable to change the customer data in EditOrder.vue.

  1 <template>
  2   <div class="container">
  3     <div v-if="showNotification" class="notification is-primary">
  4       {{$t('orderCreated.label')}}
  5     </div>
  6 
  7     <!--
  8       Shows the currenly chosen customer
  9     -->
 10     <div>
 11       <CustomerView :currentCustomer="currentCustomer"></CustomerView>
 12     </div>
 13 
 14     <div>
 15       <OrderView
 16         :currentOrder="this.currentOrder"
 17         @AmountIncremented="amountIncremented"
 18         @AmountDecremented="amountDecremented"
 19         @OrderItemRemoved="orderItemRemoved"
 20         :showAmountChanger="true"
 21       ></OrderView>
 22     </div>
 23 
 24     <div v-if="state == 'ChooseOrderItems'">
 25       <!--
 26         Here searchProducts is a function which takes a string 
 27         and returns a list of product suggestions. optionSelected event is 
 28         fired by Autocomplete, when an item from list is chosen.
 29       -->
 30       <AutoComplete
 31         :displayText="$t('searchItem.label')"
 32         :searchFn="searchProducts"
 33         @OptionSelected="productChosen"
 34       ></AutoComplete>
 35       <!--
 36         Shows the currently chosen product
 37       -->
 38       <div>
 39         <ProductView :currentProduct="currentProduct"></ProductView>
 40       </div>
 41     </div>
 42 
 43     <input
 44       class="button is-black"
 45       value="Send"
 46       type="submit"
 47       v-on:click.prevent="onSubmit"
 48     />
 49   </div>
 50 </template>
 51 
 52 <script lang="ts">
 53   import Vue from "vue";
 54   import {
 55     Order,
 56     Product,
 57     Customer,
 58     getEmptyOrderItem,
 59     OrderItem,
 60   } from "@/types/types.ts";
 61   import OrderView from "@/components/order/OrderView.vue";
 62   import { Component, Prop } from "vue-property-decorator";
 63   import orders from "@/store/modules/orders";
 64   import products from "@/store/modules/products";
 65   import AutoComplete from "../utils/AutoComplete.vue";
 66   import customers from "@/store/modules/customers";
 67   import CustomerView from "@/components/customer/CustomerView.vue";
 68   import ProductView from "@/components/product/ProductView.vue";
 69 
 70   // You can't define types inside the class
 71   type OrderCreationStage =
 72     | "ChooseCustomer"
 73     | "ChooseOrderItems"
 74     | "OrderCreated";
 75 
 76   @Component({
 77     components: {
 78       OrderView,
 79       AutoComplete,
 80       CustomerView,
 81       ProductView,
 82     },
 83   })
 84   export default class AddOrder extends Vue {
 85     private state: OrderCreationStage = "ChooseOrderItems";
 86     private currentOrder: Order | null = null;
 87     private currentCustomer: Customer | null = null;
 88     private currentProduct: Product | null = null;
 89     private showNotification = false;
 90 
 91     /**
 92      * Incoming order id
 93      */
 94     @Prop()
 95     private id!: number;
 96 
 97     public async created() {
 98       const response = await orders.service.get(this.id);
 99       this.currentOrder = response.data.data;
100     }
101 
102     /**
103      * Contacts backend using customers service and get autocompletion suggestions
104      */
105     public searchCustomers(name: string) {
106       return customers.service.search({ term: name });
107     }
108 
109     /**
110      * Contacts backend using products service and get autocompletion suggestions
111      */
112     public searchProducts(name: string) {
113       return products.service.search({ term: name });
114     }
115 
116     public amountIncremented(item: OrderItem) {
117       item.amount = item.amount + 1;
118     }
119 
120     public amountDecremented(item: OrderItem) {
121       item.amount = item.amount - 1;
122     }
123 
124     /**
125      * Checks if all required customer details are provided
126      */
127     public customerDetailsComplete(customer: Customer) {
128       return (
129         customer.phone !== "" && customer.pincode !== "" && customer.name !== ""
130       );
131     }
132 
133     public async onSubmit() {
134       if (this.currentOrder) {
135         const response = await orders.service.update(
136           this.currentOrder.id,
137           this.currentOrder
138         );
139 
140         // If status is 200, which stands for content updated
141         // and so we show a notification
142         if (response.status == 200) {
143           this.showNotification = true;
144 
145           // Hide notification after 3 seconds
146           setTimeout(() => {
147             this.showNotification = false;
148           }, 3000);
149         }
150       }
151     }
152 
153     public customerChosen(item: Customer) {
154       this.currentCustomer = item;
155     }
156 
157     /**
158      * Add the item to list of item in current order.
159      * If item already exists in the order items, we skip the item.
160      * When a product is chosen, we call this function
161      */
162     public productChosen(item: Product) {
163       if (this.currentOrder) {
164         // We need to convert the Product to an OrderItem to add into order_items
165         let orderItem = getEmptyOrderItem();
166         orderItem.product_id = item.id;
167         orderItem.amount = 1;
168 
169         // Checks if orderItem already exists with the order. If exists, 
170         // we skip it again and return.
171         let orderItemExists = this.currentOrder.order_items.filter(
172           (x) => x.product_id === item.id
173         );
174         if (orderItemExists.length !== 0) {
175           return false;
176         }
177 
178         // Update order with new order item
179         this.currentOrder.order_items.push(orderItem);
180         this.currentProduct = item;
181       }
182     }
183 
184     public orderItemRemoved(item: OrderItem) {
185       if (this.currentOrder) {
186         this.currentOrder.order_items = this.currentOrder.order_items.filter(
187           (x) => x.product_id !== item.product_id
188         );
189       }
190     }
191   }
192 </script>
193 
194 <i18n>
195   { "de": { "orderCreated": { "label": "Auftrag erstellt" }, "searchCustomer": {
196   "label": "Kunden suchen" }, "searchItem": { "label": "Nach Artikeln suchen" }
197   }, "en": { "orderCreated": { "label": "Order created" }, "searchCustomer": {
198   "label": "Search for Customers" }, "searchItem": { "label": "Search for items"
199   } } }
200 </i18n>

Yes, you are right. It is possible to abstract out the common code in EditOrder.vue and AddOrder.vue and make it smaller. We will leave it as a future task.

Now that we have the UI to handle order edits, lets take a look at backend code for enabling the modification of an order. When an order is edited, we need to pay special attention to order items. As you know when an order was created, we also created order items inside an order. Due to same reason, when an order changes the order items could also change. In order to make it easier to implement, when an order is changed, we load all existing order items for the order and delete all of them. Then we create new order items corresponding to the modified order. The code is for update_order in lib/ms/order_management.ex is as follows.

 1 def update_order(%Order{} = order, attrs) do
 2   response = order
 3              |> Order.changeset(attrs)
 4              |> Repo.update()
 5 
 6   with {:ok, order} <- response do
 7     preloaded_order = order |> Repo.preload(:order_items)
 8     existing_order_items = preloaded_order.order_items
 9     # If order items doesn't exist we provide []
10     new_order_items = attrs["order_items"] || []
11 
12     # We delete all existing order items which are not in new order items
13     for order_item <- existing_order_items do
14       case Enum.filter(new_order_items, 
15         fn x -> Map.get(x, "id") == Map.get(order_item, "id") end) do
16           [] -> delete_order_item(order_item)
17           _ -> []
18         end
19     end
20 
21     # We loop through all new order items and if already existing in our order, 
22     # we just update them.
23     for order_item <- new_order_items do
24       case Enum.filter(existing_order_items,
25         fn x -> Map.get(x, "id") == Map.get(order_item, "id") end) do
26           [] -> create_order_item(Map.merge(order_item, %{"order_id" => order.id}))
27           [found] -> update_order_item(
28             found,
29             Map.merge(order_item, %{"order_id" => order.id})
30           )
31         end
32     end
33 
34     {
35       :ok,
36       order
37       |> Repo.preload(:order_items)
38     }
39   end
40 end

Now our order editing page should look as follows.

Order editing page
Order editing page

So, now we have implemented order creation and editing functionality for our Order Management System.

Lets see how our tests are doing.

1 mix test

We will see that many of our tests fail. We will see how to fix those failing tests in next chapter.

Fixing failing Elixir tests

In this chapter, we will fix the failing tests. Let’s take a look at a failing test and see how to fix it. Maybe it can also be a bug in our code.

1 mix test test/ms/order_management_test.exs

Consider the below error message.

 1 test order_items update_order_item/2 with invalid data returns error changeset
 2   (Ms.OrderManagementTest)
 3      test/ms/order_management_test.exs:126
 4      ** (MatchError) no match of right hand side value:
 5       {:error, #Ecto.Changeset<action: :insert, changes: %{amount: 42,
 6        order_id: 1973, unit_price: 120.5}, errors: [product_id:
 7         {"can't be blank", [validation: :required]}], 
 8         data: #Ms.OrderManagement.OrderItem<>, valid?: false>}
 9      code: order_item = order_item_fixture()
10      stacktrace:
11        test/ms/order_management_test.exs:89: 
12         Ms.OrderManagementTest.order_item_fixture/1
13        test/ms/order_management_test.exs:127: (test)

As you can see test complains about missing product_id which is required. This is because our order item requires a reference to a product. In order to fix this lets add a function to build a product, so that its product id can be passed to the order item. So the product fixture in test/ms/order_management_test.exs is as follows.

 1   @valid_attrs_product %{name: "some name", price: 120.5, stock: 42, tax: 120.5}
 2 
 3   def product_fixture(attrs \\ %{}) do
 4     {:ok, product} =
 5       attrs
 6       |> Enum.into(@valid_attrs_product)
 7       |> InventoryManagement.create_product()
 8 
 9     product
10   end

Since we use create_product from InventoryManagement, we need to alias alias Ms.InventoryManagement at the top of the file.

And now in to our order_item_fixture function add product_id as follows. ```elixir def order_item_fixture(attrs \ %{}) do order = order_fixture() product = product_fixture() valid_attrs = Map.merge( @valid_attrs, %{“order_id” ⇒ order.id, “product_id” ⇒ product.id} )

1 {:ok, order_item} =
2   attrs
3   |> Enum.into(valid_attrs)
4   |> OrderManagement.create_order_item()
5  
6 order_item

end ```

This should fix 6 out of 7 failures, leaving create_order_item with valid data as the only failing one. As you notice the error is the same like before. Fix is similar to previous fixes. Take a look at code to see exactly how.

Now lets take a look at failing tests in test/ms_web/controllers/order_item_controller_test.exs.

1 mix test test/ms_web/controllers/order_item_controller_test.exs

The partial result is as follows.

 1 test create order_item renders order_item when data is valid
 2   (MsWeb.OrderItemControllerTest)
 3      test/ms_web/controllers/order_item_controller_test.exs:59
 4      ** (RuntimeError) expected response with status 201, got: 422, with body:
 5      {"errors":{"product_id":["can't be blank"]}}
 6      code: assert %{"id" => id} = json_response(conn, 201)["data"]
 7      stacktrace:
 8        (phoenix) lib/phoenix/test/conn_test.ex:373: Phoenix.ConnTest.response/2
 9        (phoenix) lib/phoenix/test/conn_test.ex:419: Phoenix.ConnTest.json_response/2
10        test/ms_web/controllers/order_item_controller_test.exs:63: (test)

The error is similar to previous case, where we lack product_id. Here too we add a function to create a product and an order. See the code below.

 1 defp create_order(_) do
 2   order= order_fixture()
 3   {:ok, order: order}
 4 end
 5 
 6 defp create_product(_) do
 7   product = product_fixture()
 8   {:ok, product: product}
 9 end
10 
11 @create_attrs_product %{
12   name: "some name",
13   price: 120.5,
14   stock: 42,
15   tax: 120.5
16 }
17 
18 def product_fixture() do
19   {:ok, product} = InventoryManagement.create_product(@create_attrs_product)
20   product
21 end

Also make sure we add alias Ms.InventoryManagement to the top of the file, as we use order_fixture and create_product from Ms.InventoryManagement.

Now we just change the test as follows.

 1 describe "create order_item" do
 2   setup [:create_product, :create_order]
 3 
 4   test "renders order_item when data is valid",
 5     %{conn: conn, product: product, order: order} do
 6     create_attrs = Map.merge(
 7       @create_attrs_order_item,
 8       %{"order_id": order.id, "product_id": product.id}
 9     )
10     conn = post(
11       conn,
12       Routes.order_item_path(conn, :create),
13       order_item: create_attrs
14     )
15     assert %{"id" => id} = json_response(conn, 201)["data"]
16 
17     conn = get(conn, Routes.order_item_path(conn, :show, id))
18     assert %{
19              "id" => id,
20              "amount" => 42,
21              "unit_price" => 120.5
22            } = json_response(conn, 200)["data"]
23   end

Here the setup [:create_product, :create_order] line says that the result of create_product and create_order is passed as input to the test. In this case it is %{conn: conn, product: product, order: order}. The conn: conn is automatically injected.

After applying a bit more polish, like renaming attributes to more meaningful names and fixing all compile errors, all our tests will pass. As the code changes are pretty trivial please take a look at the commit.

That’s all folks for this chapter. In next chapter, we will see how to deploy our app to the real world.

Deploying our application using Docker

In this chapter, we will deploy our frontend and backend using docker. During development, we have been using mix to build and run our Phoenix server. When we run our deploy to production, we wont be using mix to run our server. Elixir support releases, which enable to bundle our Phoenix application along with all its dependencies and even the BEAM VM if required. This allows easy deploy to a production machine. There are also many advantages to using release which are explained in detail in official docs

In short, it provides the following benefits.

  • Code-preloading - This loads all required modules beforehand enabling the system to handle requests as soon as started.
  • Self-contained - Releases can include BEAM VM, making installation of VM unnecessary on new servers. It also guarantees the correct version of VM.
  • Configuration - Can be used to tune the system.

From elixir 1.9 onwards releases are built in. Phoenix docs provides a step by step guide to build and deploy phoenix apps. So lets build ourselves a new release.

Initialize a new release

A new release can be initialized as follows.

1 mix release.init

This will create the rel/ folder at the root. They can be used to tune the system and add configuration. We will get back to this later.

In our config/prod.secret.exs we load the database url and secret key base as environment variables, as shown below.

1 use Mix.Config
2 
3 database_url =
4   System.get_env("DATABASE_URL") ||
5     raise """
6     environment variable DATABASE_URL is missing.
7     For example: ecto://USER:PASS@HOST/DATABASE
8     """

The issue with the code is that, this code is executed on the compiling machine and copied over to releases. In simple terms, we get value of DATABASE_URL from the compiling machine and it is not taken from the deployed/production machine. It is a bit strange, but it is true :). In order to fix this, releases support runtime configuration. Follow the below steps to convert our prod.secret.exs, so that it is loaded at runtime and not at compile time.

  1. Rename config/prod.secret.exs to config/releases.exs
  2. Replace use Mix.Config in config/releases.exs to import Config.
  3. Remove import_config “prod.secret.exs from config/prod.exs file.

The contents of config/releases.exs will be as follows.

 1 import Config
 2 
 3 database_url =
 4   System.get_env("DATABASE_URL") ||
 5     raise """
 6     environment variable DATABASE_URL is missing.
 7     For example: ecto://USER:PASS@HOST/DATABASE
 8     """
 9 
10 config :ms, Ms.Repo,
11   # ssl: true,
12   url: database_url,
13   pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")
14 
15 secret_key_base =
16   System.get_env("SECRET_KEY_BASE") ||
17     raise """
18     environment variable SECRET_KEY_BASE is missing.
19     You can generate one by calling: mix phx.gen.secret
20     """
21 
22 config :ms, MsWeb.Endpoint,
23   http: [:inet6, port: String.to_integer(System.get_env("PORT") || "4000")],
24   secret_key_base: secret_key_base

As can be seen above, we need to provide values for environment variables SECRET_KEY_BASE and DATABASE_URL.

Phoenix requires SECRET_KEY_BASE to sign and encrypt data, in order to avoid tampering. We can use the below command to generate the secret and set it as an environment variable in our system.

1 mix phx.gen.secret
2 A secret key
3 export SECRET_KEY_BASE=A secret key

Database url can be set as follows. It follows the pattern ecto://USER:PASS@HOST/database?port=portNo. Since we ran on port 5433, as opposed to 5433 we need to provide the port too.

1 export DATABASE_URL=ecto://postgres:postgres@localhost/ms_prod?port=5433

Phoenix by default doesn’t start the server with releases. To support this we need to turn on server in config/prod.ex as follows. Just uncomment the below line.

1 config :ms, MsWeb.Endpoint, server: true

Now we are ready to make our release.

Compiling and build release

Follow the below commands to make release.

1 # Get dependencies required for production
2 mix deps.get --only prod
3 # Compile code
4 MIX_ENV=prod mix compile
5 # compile other assets like js/css
6 mix phx.digest
7 # Build release
8 MIX_ENV=prod mix release

Starting the server

1 # Start the server
2 _build/prod/rel/ms/bin/ms start

Now we will get an error like below.

1 15:36:16.079 [error] Could not find static manifest at "/_build/prod/rel/ms/lib/ms-0\
2 .1.0/priv/static/cache_manifest.json". Run "mix phx.digest" after building your stat\
3 ic files or remove the configuration from "config/prod.exs".

This is because we didn’t run the mix phx.digest. phx.digest task digests and compresses static files. Our static Javascript files are in our client repository. So there are two ways to do this. Either we serve the static files with a server like nginx or we use Phoenix to serve static files too. If we use Phoenix, we have to build the files in our client repository, copy it over to priv/static and run phx.digest. Here we chose the first option. This decision, makes our elixir releases separate from our UI deployments.

Deploying Static files with Nginx and docker

We want nginx to redirect all /api/v1/ requests to Phoenix and all requests to Vue. Lets take a look at our nginx.conf file. Please take a look at the comments too.

 1 server {
 2     listen 80;
 3     # Our hostname, can be changed if hosting on a domain
 4     server_name localhost;
 5     # Root of nginx
 6  	root /usr/share/nginx/html/;
 7 
 8     # All /api/v1 calls will be redirected to Phoenix
 9     location /api/v1/ {
10         proxy_pass http://localhost:4000/api/v1/;
11         proxy_set_header Host "localhost";
12     }
13 
14     # Static files served from here
15     # Using try_files we rewrite all url to /index.html which will be handled by Vue
16     location /{
17         try_files $uri /index.html;
18     }
19 }

Now lets test-drive this config with an nginx docker container. Before we do this, we need to generate the JS files from client. This can be easily done using npm build.

1 npm run build

Now we have a dist folder at root of project with all the js/css files. We can directly mount this folder as the root folder for nginx. In docker it can be done as below.

1 docker run -p 80:80 -v <full absolute path to folder>/dist/:/usr/share/nginx/html/:r\
2 o -v <full absolute to nginx folder>/nginx.conf:/etc/nginx/conf.d/default.conf --net\
3 work="host" nginx

Here we use –network=”host” for testing purposes. This binds the nginx container to our host, so that all ports in host is accessible to nginx container. This is required as our Phoenix is running on our host machine. Once we make sure everything works fine, we will everything to a docker-compose file.

As we can see everything works fine and all our apps run in production mode. Now lets write our docker-compose.yml file and Dockerfile which handles everything from building of binaries to deploying them.

Docker file for Phoenix Server

As you know, when you need to build your own container. You need to write a Dockerfile. This file tells docker what commands to execute to build and run our docker container. Lets take a look at our Dockerfile for backend inside shopmanagementserver folder. Please check the comments inside the file. Here we use docker multi-stage build. This allows us to use multiple temporary containers to build our app and copying only required build artifacts from those temporary containers to our app container.

 1 # Setup first temporary container for building elixir.
 2 FROM elixir:1.9.2-alpine as build-elixir
 3 
 4 # Install build dependencies
 5 RUN apk add --update git
 6 
 7 # Set our work directory in docker container
 8 RUN mkdir /app
 9 WORKDIR /app
10 
11 # Install hex and rebar
12 RUN mix local.hex --force && \
13     mix local.rebar --force
14 
15 # set build ENV. This might not be necessary
16 ENV MIX_ENV=prod
17 
18 # Install all mix dependencies
19 COPY mix.exs mix.lock ./
20 RUN mix deps.get
21 
22 # Compile all mix dependencies
23 COPY config ./config/
24 RUN mix deps.compile
25 
26 # Now we selectively copy all required files from local system to docker container
27 COPY lib ./lib/
28 COPY priv ./priv/
29 COPY rel ./rel/
30 
31 # Run digest. This is not required, as we don't serve any static files from phoenix.
32 RUN mix phx.digest
33 
34 # Build our release
35 RUN mix release
36 
37 # Now this container will have our Elixir release compiled.
38 # We just need to copy it to our production container.
39 # We use multiple containers because, we don't want to have all
40 # development tools and files in our production server.
41 
42 # Production Elixir server
43 FROM alpine:3.10
44 
45 # Install openssl and bash
46 RUN apk add --update bash openssl
47 
48 RUN mkdir /app
49 WORKDIR /app
50 
51 # We run as root, we can change it in the future
52 USER root
53 
54 # Copy all build artifacts from previous container. Notice the --from
55 COPY --from=build-elixir /app/_build/prod/rel/ms ./
56 
57 # Copy required scripts to run when the container starts
58 COPY entrypoint.sh .
59 
60 # Setting environment variables
61 ARG VERSION
62 ENV VERSION=$VERSION
63 ENV REPLACE_OS_VARS=true
64 
65 # Make our build runnable
66 RUN chmod 755 /app/bin/ms
67 
68 # Expose our app to port 4000
69 EXPOSE 4000
70 
71 # These two environment variables are checked in phoenix server,
72 # to find the secret key and database url.
73 ENV DATABASE_URL=ecto://postgres:postgres@postgres/ms_prod
74 ENV SECRET_KEY_BASE=6jSLHKOk3s645E27EZVULIAuopigrSaiTgi+aKz7dtqKw0qRwjKWwQIkXqyyzkZc
75 
76 # Set script to run when the server starts
77 CMD ["./entrypoint.sh"]

Contents of shopmanagementserver/entrypoint.sh is as follows.

1 #!/bin/sh
2 # Docker entrypoint script.
3 
4 # Sets up tables and running migrations.
5 /app/bin/ms eval "Ms.Release.migrate"
6 # Start our app
7 /app/bin/ms start

Running Postgres migrations from Docker

You might be wondering why we need this Ms.Release.migrate task in Docker and not before. In previous case we had our postgres database already setup for us by mix ecto.create and mix ecto.migrate. In production system we don’t have access to mix. So we need to write a migrate script in Phoenix app and call it before starting the phoenix app in production. We place the release.ex file inside lib/ms folder. The contents are as follows.

 1 defmodule Ms.Release do
 2   """
 3   Release script for running migrations. migrate function runs all migrations
 4   """
 5   @app :ms
 6 
 7   def migrate do
 8     # Get all repos and run ecto migrate
 9     for repo <- repos() do
10       {:ok, _, _} =
11         Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
12     end
13   end
14 
15   def rollback(repo, version) do
16     {:ok, _, _} =
17       Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
18   end
19 
20   # loads all repos for our app
21   defp repos do
22     Application.load(@app)
23     Application.fetch_env!(@app, :ecto_repos)
24   end
25 end

This can be triggered in release using eval

1 # Syntax is app_binary eval full_function_path
2 /app/bin/ms eval "Ms.Release.migrate"

Now when our server starts, it will have executed all migrations and hence will be ready to serve requests.

Dockerfile for VueJS client

Similarly we need to build and run our Client. The contents of shopmanagementclient/Dockerfile is as follows.

 1 # First temporary container to build the vuejs app
 2 FROM node:10.16-alpine as build-node
 3 
 4 # prepare build dir
 5 RUN mkdir -p /app/assets
 6 WORKDIR /app
 7 
 8 # Manually copy all required files.
 9 COPY package.json package-lock.json ./assets/
10 COPY vue.config.js ./assets/vue.config.js
11 COPY src ./assets/src/
12 COPY .env ./assets/.env
13 COPY babel.config.js ./assets/babel.config.js
14 COPY postcss.config.js ./assets/
15 COPY tsconfig.json ./assets/
16 RUN cd assets && npm install --dev --force
17 
18 # Build our application
19 RUN cd assets && npm run build
20 
21 # Our production VueJS app. We will serve it from nginx directly as they are static.
22 FROM nginx
23 # Listen on port 80
24 EXPOSE 80
25 
26 # As you can see, we simply reused the nginx.conf we used before with a small change
27 COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf
28 # copy all build artifacts from build-node. Notice the --from
29 COPY --from=build-node /app/assets/dist/ /usr/share/nginx/html/

The only change in shopmanagementclient/nginx/nginx.conf file is as follows. We will host our phoenix app in docker-compose with service name web. That is why we change the name of server from localhost to web. You will see the docker-compose.yml file soon.

1 server {
2     # All /api/v1 calls will be redirected to Phoenix
3     location /api/v1/ {
4         # As we run docker compose, we use name of the our phoenix service, ie web
5         proxy_pass http://web:4000/api/v1/;
6     }

Now the only step left is to combine all build docker containers with docker-compose. The contents of docker-compose.yml file in shopmanagementdeploy is as follows.

 1 version: '3'
 2 services:
 3   # Service web
 4   web:
 5     # Build file for server is in that folder
 6     build: ../shopmanagementserver/
 7     # This service needs postgres service to be up.
 8     depends_on:
 9       - postgres
10 
11   # Service client
12   client:
13     # Build file for client is in that folder
14     build: ../shopmanagementclient/
15     # Expose container port 80 to host port 80
16     ports:
17       - "80:80"
18     depends_on:
19     # For this service to work, servie web should be up.
20       - web
21 
22   # Database service
23   postgres:
24     image: postgres
25     volumes:
26       # Mount ./data fro host to /var/lib/postgresql/data in container
27        - ./data:/var/lib/postgresql/data
28     # Environment variables to be passed to postgres
29     environment:
30       POSTGRES_DB: ms_prod
31       POSTGRES_PASSWORD: postgres
32       POSTGRES_USER: postgres
33     # Map 5432 port from container to port 5434 in host
34     ports:
35       - "5434:5432"

Start docker compose with Nginx and Phoenix app

1 # Builds all containers
2 docker-compose build
3 # Starts docker-compose
4 docker-compose up

Now our server should be available at localhost. Let’s try to add a new product. And going to products page, we can confirm everything works fine.

Thats all folks. In next chapter we will make our VueJS client a bit more user friendly.

Making our UI user friendly

In this chapter, we will make our UI user friendly. We have already built interfaces for CRUD operations. We just need to add a nice UI to choose all available functionalities. Also search functionality would be nice, specifically for Customers and Products. We will also add some links for computing statistics, even though we won’t build the functionality in this part :D . We will name it HomePage. It will be as follows.

add product Product management home page

Building a HomePage

So lets get started. We will build a nice page with operations for orders, products and customers. Since the basic layout of all these pages are the same and requires no advanced JS, we can build a base HTML layout, which can be reused. The contents of src/components/utils/HomePage.vue are as follows.

 1 <template>
 2   <div class="tile is-ancestor">
 3     <!--
 4       The divs are aligned row by row.
 5       For this reason we make a div and assign is-vertical to parent div.
 6       This enables us to align vertically.
 7       When two these parent is-vertical divs are used, it is combined horizontally
 8       providing us with the required layout.
 9       -->
10     <div class="tile is-parent is-vertical">
11       <article class="tile is-child box">
12         <router-link :to="add.link">
13           <p class="title">{{add.title}}</p>
14           <p class="subtitle">{{add.subTitle}}</p>
15           <div class="content">
16             <i class="fas fa-plus fa-5x"></i>
17           </div>
18         </router-link>
19       </article>
20       <article class="tile is-child box">
21         <router-link :to="stats.link">
22           <p class="title">{{stats.title}}</p>
23           <p class="subtitle">{{stats.subTitle}}</p>
24           <div class="content">
25             <i class="fas fa-chart-pie fa-5x"></i>
26           </div>
27         </router-link>
28       </article>
29     </div>
30     <div class="tile is-parent is-vertical">
31       <article class="tile is-child box">
32         <router-link :to="all.link">
33           <p class="title">{{all.title}}</p>
34           <p class="subtitle">{{all.subTitle}}</p>
35           <div class="content">
36             <i class="fas fa-list fa-5x"></i>
37           </div>
38         </router-link>
39       </article>
40       <article class="tile is-child box">
41         <router-link :to="search.link">
42           <p class="title">{{search.title}}</p>
43           <p class="subtitle">{{search.subTitle}}</p>
44           <div class="content">
45             <i class="fas fa-search fa-5x"></i>
46           </div>
47         </router-link>
48       </article>
49     </div>
50   </div>
51 </template>
52 
53 <script lang='ts'>
54 import { HomePageBoxContent } from "@/types/types";
55 import { Component, Prop } from "vue-property-decorator";
56 import Vue from "vue";
57 
58 @Component({
59   components: {}
60 })
61 export default class HomePage extends Vue {
62 
63   /**
64    * We handle add/list/statistics/search operations as props, so that it can reused
65    * The HomePageBoxContent is defined as 
66    * type HomePageBoxContent = {link: String, title: String, subTitle: String
67    */
68   @Prop()
69   public add!: HomePageBoxContent;
70   @Prop()
71   public all!: HomePageBoxContent;
72   @Prop()
73   public stats!: HomePageBoxContent;
74   @Prop()
75   public search!: HomePageBoxContent;
76   public created() {}
77 }
78 </script>
79 
80 <style lang='sass' scoped>
81 
82 </style>

As you can see we have introduce the HomePageBoxContent type in types/types.ts.

1 export type HomePageBoxContent = {link: String, title: String, subTitle: String}

This will ensure that we got all required information to display a bulma tile

Now we will build specific HomePage components. Lets start with components/customer/CustomerHome.vue.

 1 <template>
 2   <HomePage :add="add" :stats="stats" :all="all" :search="search" />
 3 </template>
 4 
 5 <script lang='ts'>
 6 import { HomePageBoxContent } from "@/types/types";
 7 import { Component, Prop } from "vue-property-decorator";
 8 import HomePage from "@/components/utils/HomePage.vue";
 9 import Vue from "vue";
10 
11 @Component({
12   components: {
13     HomePage
14   }
15 })
16 export default class ProductHome extends Vue {
17   public created() {}
18 
19   private add: HomePageBoxContent = {
20     link: "/customers/add",
21     title: "Add Customer",
22     subTitle: "Add Customer"
23   };
24   private all: HomePageBoxContent = {
25     link: "/customers/all",
26     title: "View All Customers",
27     subTitle: "View All Customers"
28   };
29   private stats: HomePageBoxContent = {
30     link: "/customers/stats",
31     title: "Customer Statistics",
32     subTitle: "Customer Statistics"
33   };
34   private search: HomePageBoxContent = {
35     link: "/customers/search",
36     title: "Search Customer",
37     subTitle: "Search Customer"
38   };
39 }
40 </script>
41 
42 <style lang='sass' scoped>
43 
44 </style>

As you can see, there is nothing special with this component. Similarly we can build HomePage for products and orders.

Embedding Views with Vue Slots

Now we lets turn to adding search functionality. If you remember our components/utils/Autocomplete.vue, which offers autocomplete, we maybe able to just reuse it again. Only requirement for our search would be to display entries in autocomplete specific to product/orders/customers. Our Autocomplete.vue just shows the name all the time, which is not enough. But it can be easily fixed with vueJS slots. Lets see how we need to modify our Autocomplete.vue.

 1 <template>
 2     <ul name="autocomplete" v-if="active">
 3       <li
 4         :value="item.name"
 5         v-for="item in autoCompleteSuggestions"
 6         v-bind:key="item.id"
 7         @click.prevent="selectionChanged(item)"
 8         class="button is-fullwidth"
 9         style="border-radius: 0px"
10       >
11         <!--
12           Slot gets a name with name attribute. Here it is itemView. 
13           Then we bind :item to item from v-for loop so that we can use it
14           inside our slot.
15         -->
16         <slot name="itemView" :item="item">
17           <!-- Fallback content -->
18           {{ item.name }}
19         </slot>
20       </li>
21     </ul>
22 </template>

As you can notice we simply replaced html {{item.name}} with a slot. html <slot name="itemView" :item="item"> <!-- Fallback content --> {{ item.name }} </slot>

Now we just need to pass values to this slot. Lets look at src/components/order/AddOrder.vue to see how its done. Please take a look at inline comments for details.

 1   <AutoComplete
 2     :displayText="$t('searchItem.label')"
 3     :searchFn="searchProducts"
 4     @OptionSelected="productChosen"
 5   >
 6   <!--
 7     Here with v-slot:itemView, we say we need to fill the slot named itemView.
 8     With "{item}", we use ES2015 destructing to get item value passed to 
 9     slot. This is because, vue internally wraps contents of a scoped slot into  
10     a single argument function with slot props as the argument.
11     See https://vuejs.org/v2/guide/components-slots.html#Destructuring-Slot-Props
12   -->
13   <template v-slot:itemView="{item}">
14     {{item.name}}
15   </template>

Now we just need to build search functionality for products and customers using Autocomplete.vue. Lets take a look at src/components/customer/CustomerSearch.vue.

 1 <template>
 2 <div>
 3          <AutoComplete
 4         :displayText="$t('searchCustomer.label')"
 5         :searchFn="searchCustomers"
 6         @OptionSelected="customerChosen"
 7       >
 8       <!--
 9         Here with v-slot:itemView, we say we need to fill the slot named itemView.
10         With "{item}", we use ES2015 destructing to get item value passed to 
11         slot. This is because, vue internally wraps contents of a scoped slot into  
12         a single argument function with slot props as the argument. See 
13         https://vuejs.org/v2/guide/components-slots.html#Destructuring-Slot-Props
14       -->
15       <template v-slot:itemView="{item}">
16         {{item.name}}
17         {{item.phone}}
18       </template>
19       </AutoComplete>
20       </div>
21 </template>
22 
23 <script lang='ts'>
24 import Vue from 'vue';
25 import { Customer } from '@/types/types.ts';
26 import AutoComplete from '../utils/AutoComplete.vue';
27 import CustomerView from '@/components/customer/CustomerView.vue';
28 import { Component, Prop } from 'vue-property-decorator';
29 import customers from '@/store/modules/customers';
30 import { setTimeout } from 'timers';
31 
32 @Component({
33   components: {
34       AutoComplete
35   },
36 })
37 export default class SearchCustomer extends Vue {
38    /**
39    * Contacts backend using customers service and get autocompletion suggestions
40    */
41   public searchCustomers(name: string) {
42     return customers.service.search({ 'term': name });
43   }
44 
45 
46   public customerChosen(item: Customer) {
47       // Move to page
48       this.$router.push('/customers/edit/'+item.id);
49   }
50 }
51 </script>

Similarly, we add components/product/ProductSearch.vue. Now the only task left is to add paths in src/router.ts. Since it is pretty trivial, I leave it out. Please check the code, if you are interested.

So with this we can come to the end of this book. Thanks a lot for sticking with me till the end of this book. I hope you enjoyed working through it as much as I enjoyed writing it.

Have a nice day and take care :)