Table of Contents
- What are we going to build
- Setting up Your Phoenix Application
- Setting up a PostgreSQL backed HTTP server
- Generate Vue Typescript Application
- Add Multi-language support
- Building our Homepage view component
- Adding Vuex to Vue
- Writing tests with Jest
- Build Customer Management Features
- Refactoring Our Frontend
- Refactoring and adding tests for Backend
- Adding Order Management
- Adding new features to Order Management
- Fixing failing Elixir tests
- Deploying our application using Docker
- Making our UI user friendly
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](/site_images1/hello-web-development-with-phoenix-vue/proto.png)
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](/site_images1/hello-web-development-with-phoenix-vue/product.png)
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](/site_images1/hello-web-development-with-phoenix-vue/part5.png)
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
Building Product Related Components
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](/site_images1/hello-web-development-with-phoenix-vue/vuex.png)
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](/site_images1/hello-web-development-with-phoenix-vue/products-add.png)
We can also go to products to see all of our products.
![list products](/site_images1/hello-web-development-with-phoenix-vue/product-list.png)
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](/site_images1/hello-web-development-with-phoenix-vue/edit-product.png)
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](/site_images1/hello-web-development-with-phoenix-vue/customers-add.png)
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](/site_images1/hello-web-development-with-phoenix-vue/add-order-workflow.png)
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](/site_images1/hello-web-development-with-phoenix-vue/add-order-incomplete.png)
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](/site_images1/hello-web-development-with-phoenix-vue/observer.png)
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"
>
</
td
>
33
<
td
v-if
=
"showAmountChanger"
@
click
=
"minusClicked(item)"
>
34
<
i
class
=
"fas fa-minus"
></
i
>
35
</
td
>
36
<
td
v-if
=
"showAmountChanger"
>
</
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](/site_images1/hello-web-development-with-phoenix-vue/add-order-complete.png)
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](/site_images1/hello-web-development-with-phoenix-vue/edit-order.png)
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.
- Rename config/prod.secret.exs to config/releases.exs
- Replace use Mix.Config in config/releases.exs to import Config.
- 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.
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 :)