In this lesson, we're going to **extend the functionality of our API **so that it can create new rooms for us. We'll then call our API from the client and show the newly created data in our UI through Redux actions.
Adding an endpoint
We'll start with the API endpoint for creating new rooms! Since we've already set rooms
as a resource in the router, it should already have a good default endpoint and controller action for this. To find out what it is, we can run the mix phx.routes
command again:
Our list of routes in Phoenix.
Here we see that POST /api/rooms
is linked to a :create
action on the RoomController
. That seems to be what we want, so let's create that action!
We will assume that, when the client sends the request, it also sends a JSON with the counterpart's username as a parameter (under the key "counterpartUsername"
). That way, we can know which two users to put in the newly created room. To handle this use case we're also going to create a new create_room_with_counterpart/2
-function in our chat context.
# lib/messengyr_web/controllers/api/room_controller.ex
defmodule MessengyrWeb.RoomController do
# ...
def create(conn, %{"counterpartUsername" => counterpart_username}) do
user = Guardian.Plug.current_resource(conn)
Chat.create_room_with_counterpart(user, counterpart_username)
end
# ...
# lib/messengyr/chat/chat.ex
defmodule Messengyr.Chat do
# ...
alias Messengyr.Accounts.User # Don't forget to alias the User!
# ...
def create_room_with_counterpart(me, counterpart_username) do
counterpart = Repo.get_by!(User, username: counterpart_username)
IO.inspect counterpart
end
# ...
end
For now, we'll just fetch the counterpart (based on the username) and log it. We use the get_by!/2
-function with an exclamation mark, because we want the request to fail if the username doesn't exist. In order to actually test this request, we need to head back to our React frontend...
Handling events in React
In our Messages app, the goal is to have a feature where the user can simply click the compose-button, type in the username of the person that they want to create a new room with, hit enter, and start chatting!
This is the button that we want to attach this functionality to.
To do this, we'll add an onClick
event to our button
element:
// assets/js/components/menu-container.js
// ...
class MenuContainer extends React.Component {
// ...
render() {
// ...
return (
// ...
<div className="header">
<h3>Messages</h3>
<button
className="compose"
onClick={this.createRoom}
></button>
</div>
// ...
)
}
}
In our createRoom
event, we'll display a simple prompt window for the user where they can type in the username. It's not the prettiest UI ever, but it works!
// assets/js/components/menu-container.js
// ...
class MenuContainer extends React.Component {
// ...
// Add this function:
createRoom() {
let username = prompt("Enter a username");
console.log(username);
}
render() {
// ...
}
}
// ...
If you click the button, you should now see this prompt! Type in a username, click "OK", and you'll see that username logged in your JavaScript console.
To send a POST
request to api/rooms
, we'll use our beloved "Fetch" again! Note that we need to wrap the username in a FormData
object under the key counterpartUsername
, so that it gets picked up by our controller function in Phoenix:
// assets/js/components/menu-container.js
// ...
class MenuContainer extends React.Component {
// ...
createRoom() {
let username = prompt("Enter a username");
let data = new FormData();
data.append("counterpartUsername", username);
fetch('/api/rooms', {
method: "POST",
headers: {
"Authorization": "Bearer " + window.jwtToken,
},
body: data,
})
.then((response) => {
return response.json();
})
.then((response) => {
console.log(response);
})
.catch((err) => {
console.error(err);
});
}
// ...
}
// ...
If you try this again with the username "bob", you'll notice that the request fails, since our create
-action in the Phoenix controller doesn't return a conn
. However, we should still see bob's user struct in the Elixir log, which means that everything is working up to this point!
There's bob's info, which means that the database managed to retrieve the user based on the username we sent!
Creating a new room
Let's continue handling this request in Elixir. Our next step is to create an array of all the users that should be added to the new room. In this case, there are just two users -- the counterpart and yourself. We'll create an array from these and log it:
# lib/messengyr/chat/chat.ex
defmodule Messengyr.Chat do
# ...
def create_room_with_counterpart(me, counterpart_username) do
counterpart = Repo.get_by!(User, username: counterpart_username)
members = [counterpart, me]
IO.inspect members
end
# ...
end
Go back to your browser, click the button and type in "bob" as the username again, then check you console:
There's our array with "bob" and our own user!
We now need to create a new room and add each user in this list ("bob" and yourself) as members (a.k.a. RoomUsers
) of that room. We'll use the new with
control structure to read and match the result of create_room/0
:
# lib/messengyr/chat/chat.ex
defmodule Messengyr.Chat do
# ...
def create_room_with_counterpart(me, counterpart_username) do
counterpart = Repo.get_by!(User, username: counterpart_username)
members = [counterpart, me]
with {:ok, room} <- create_room() do
add_room_users(room, members)
end
end
# ...
end
This with
-syntax is basically just another way of writing the following case
statement:
case create_room() do
{:ok, room} -> add_room_users(room, members)
end
Adding members through recursion
As you can see from the code above, we still need to define an add_room_users/2
function (not to be confused with the existing add_room_user/2
, with a singular user). In this function, we're going to use a nifty functional programming pattern called recursion!
The basic idea is that, every time add_room_users/2
is called, we extract the first user from the list, add it to the room, then call the same function again with the rest of the users. We keep doing this for the first user in the list over and over again, until the rest of the list is empty. Sounds cool right?
So how do we implement this? As you might remember from the "Elixir basics" chapter, a list consists of a head (the first element of the list) and a tail (the rest of the elements). We can easily separate the two by using pattern matching and the **pipe character **(|
). Let's quickly try it out in IEx to see how it works:
iex(1)> [first | rest] = [1, 2, 3, 4]
[1, 2, 3, 4]
iex(2)> first
1
iex(3)> rest
[2, 3, 4]
Doesn't seem too hard. With this in mind, let's implement add_room_users/2
and use pattern matching with |
to extract the first user. Remember that our function's first argument is the newly created Room
struct, and the list of users is the second:
# lib/messengyr/chat/chat.ex
defmodule Messengyr.Chat do
# ...
defp add_room_users(room, [first_user | other_users]) do
# We add the first room_user to the database:
case add_room_user(room, first_user) do
{:ok, _} ->
#... then we let the function call itself:
add_room_users(room, other_users)
_ ->
{:error, "Failed to add user to room!"}
end
end
# ...
end
Now, if we only had this function declaration, it's going to fail at its last attempt to call add_room_users/2
from itself, because other_users
will be empty, and it can therefore not be pattern matched against a list with a head and a tail. The trick is therefore to **define a second **add_room_users/2
-function, which explicitly matches this empty list instead! We can declare this one right above the first one:
# lib/messengyr/chat/chat.ex
defmodule Messengyr.Chat do
# ...
# Add this function:
defp add_room_users(room, []) do
{:ok, room}
end
defp add_room_users(room, [first_user | other_users]) do
# ...
end
# ...
end
Now, add_room_users/2
should keep looping until it outputs {:ok, room}
! If you're still a bit confused about how this works, the following picture might help clarify:
Displaying the new room in JSON
When we display the room, we also want to make sure that we've preloaded the messages and users, so that it matches the behaviour of our other requests. To do this, we'll create a new preload_room_data/1
function that takes a room struct and adds the extra data to it. We'll use this as we return the tuple from the last add_room_users/1
call:
# lib/messengyr/chat/chat.ex
defmodule Messengyr.Chat do
# ...
defp preload_room_data(room) do
room |> Repo.preload(:messages) |> Repo.preload(:users)
end
# ...
defp add_room_users(room, []) do
{:ok, room |> preload_room_data}
end
# ...
end
While we're at it, we can also use preload_room_data/1
in list_user_rooms/1
and list_rooms/0
, to avoid code repetition:
# lib/messengyr/chat/chat.ex
defmodule Messengyr.Chat do
# ...
def list_user_rooms(user) do
query = from r in Room,
join: u in assoc(r, :users),
where: u.id == ^user.id
Repo.all(query) |> preload_room_data
end
def list_rooms do
Repo.all(Room) |> preload_room_data
end
# ...
end
Let's go back to the RoomController
. We can now call create_room_with_counterpart/2
from there, and the function will take care of almost everything for us. What's left is rendering the newly created room in JSON. For that, we use the render/3
-function as usual:
# lib/messengyr_web/controllers/api/room_controller.ex
defmodule MessengyrWeb.RoomController do
# ...
def create(conn, %{"counterpartUsername" => counterpart_username}) do
user = Guardian.Plug.current_resource(conn)
with {:ok, room} <- Chat.create_room_with_counterpart(user, counterpart_username) do
render(conn, "show.json", %{
room: room,
me: user,
})
end
end
# ...
end
As you can see, we're specifying that we want to use "show.json"
as the template. In our RoomView
however, we currently only pattern match against "index.json"
. Let's fix that!
# lib/mesengyr_web/views/api/room_view.ex
defmodule MessengyrWeb.RoomView do
# ...
# Add this function:
def render("show.json", %{room: room, me: me}) do
%{
room: room_json(room, %{me: me})
}
end
def render("index.json", %{rooms: rooms, me: me}) do
# ...
end
Alright! Now let's go back to the browser and try one more time! Make sure you type in "bob" as the username in the prompt, hit enter, and you should see this in your console:
It worked! There's our new room!
Fixing the UI
If you refresh the browser after you've created you new room, you're going to see this nasty error in your JavaScript log:
"Cannot read property 'sentAt' of undefined"
This is because we naively expected our rooms to always have at least one message when we designed the render
-function in the MenuMessage
component. However, newly created rooms don't have any messages yet, so this is obviously a flaw in our code. Let's fix it!
// assets/js/components/menu-message.js
// ...
class MenuMessage extends React.Component {
// ...
render() {
// ...
let lastMessage = room.messages.slice(-1)[0];
// Declare 2 new variables:
let sentAt;
let text;
// Only set "sentAt" and "text" if lastMessage exists:
if (lastMessage) {
sentAt = moment.utc(lastMessage.sentAt).fromNow();
text = lastMessage.text;
}
// ...
return (
<li className={activeClass}>
// ...
<p className="message">
{text} // Also change this line!
</p>
// ...
Now, both of our rooms with "bob" should render nicely!
Sorting the rooms by date
Another flaw in our current UI is that the rooms aren't properly sorted. Right now, they only appear in the order that our API returns them in -- sorted by ID.
What we want is to have the rooms sorted in a reverse order, so that the one with the newest message appears first. If a room has no messages, we want to use its creation date as a reference instead.
The first thing we need to do for this to work is to return the creation date in our JSON API. As you know, our room
schema already has an inserted_at
field, so we'll simply use that one under the key createdAt
:
# lib/mesengyr_web/views/api/room_view.ex
defmodule MessengyrWeb.RoomView do
# ...
defp room_json(%{users: room_users} = room, %{me: me}) do
counterpart = get_counterpart(room_users, me)
%{
id: room.id,
counterpart: user_json(counterpart),
messages: Enum.map(room.messages, fn(message) -> message_json(message, %{me: me}) end),
createdAt: room.inserted_at, # Add this line!
}
end
# ...
end
With this new JSON, we'll go back to React and create a getRoomDate
-function which will return a given room's last message's sentAt
-date. If the room doesn't have any messages, we'll return its createdAt
date. We will then use this new function when we loop over our rooms and sort them:
// assets/js/components/menu-container.js
// ...
class MenuContainer extends React.Component {
// ...
render() {
// Get a date from a room we want to sort:
let getRoomDate = (room) => {
let date;
if (room.lastMessage) {
date = room.lastMessage.sentAt;
} else {
date = room.createdAt;
}
return new Date(date);
};
// Sort the rooms (by date, descending):
let rooms = this.props.rooms.sort((a, b) => {
return getRoomDate(b) - getRoomDate(a);
});
// Use the sorted rooms when we render the component:
rooms = rooms.map((room) => {
return (
<MenuMessage
key={room.id}
room={room}
/>
);
});
return (
// ...
)
}
}
Refresh the page and you'll now see the newly created room appear first in the list!
Selecting a room
Our rooms look good in the menu bar, but if we click on them, nothing happens! We obviously want the room to be selected and its messages to appear on the right side. Fortunately, we've already written a Redux action that handles this action: "SELECT_ROOM"
. The only thing left to do is to connect that action to a click event on our MenuMessage
component!
We'll start by mapping the Redux action selectRoom
to a prop on MenuMessage
. Make sure that you import the necessary functions and use mapDispatchToProps
at the bottom of your file:
// assets/js/components/menu-message.js
// ...
// Import these:
import { connect } from 'react-redux';
import { selectRoom } from '../actions';
class MenuMessage extends React.Component {
// ...
}
// Add the "selectRoom" action to "mapDispatchToProps":
const mapDispatchToProps = {
selectRoom,
};
// Connect the actions to Redux:
MenuMessage = connect(
null, // We're not using "mapStateToProps", so we put "null" here
mapDispatchToProps,
)(MenuMessage);
export default MenuMessage;
Next, we want to call this action with the room's ID when we click on the component. For that we'll create an onClick
event. We also have to make sure that we use bind(this)
for the event, otherwise we can't access this.props
in the component method:
// assets/js/components/menu-message.js
// ...
class MenuMessage extends React.Component {
// Create this component method:
selectRoom() {
let roomId = this.props.room.id;
// Call the Redux action:
this.props.selectRoom(roomId);
}
render() {
// ...
return (
<li
className={activeClass}
onClick={this.selectRoom.bind(this)} // Add this event
>
// ...
</li>
)
}
}
// ...
And that's all you need in order to be able to toggle between your rooms! By calling a single action, all the components tied to the store get re-rendered and display the relevant information. Are you starting to see the advantages of Redux?
Displaying newly created rooms instantly
Even though we can create new rooms now, they won't show up in our UI until we actively refresh the page. That's not good enough! Let's write a Redux action called "ADD_ROOM"
so that we can make them show up instantly.
We'll start by creating the action in the actions.js
-file:
// assets/js/actions.js
// ...
export function addRoom(room) {
return {
type: "ADD_ROOM",
room,
}
};
The reducer will be almost as simple. We simply read the action.room
-object and put it at the top of the array (using JavaScript's spread syntax).
// assets/js/reducers.js
// ...
const rooms = (state = [], action) => {
switch (action.type) {
// ...
// Add this case:
case "ADD_ROOM":
return [
action.room,
...state,
];
default:
return state;
}
};
// ...
Finally, we make sure that we call the action in our component, right after the Fetch request is done. Remember to use .bind(this)
again in the template, since we need to access the props in the createRoom()
method!
// assets/js/components/menu-container.js
// ...
// Make sure that you add "addRoom" to this list:
import { setRooms, selectRoom, addRoom } from '../actions';
class MenuContainer extends React.Component {
// ...
createRoom() {
// ...
fetch('/api/rooms', {
// ...
})
.then((response) => {
let room = response.room;
// Call the action from your component:
this.props.addRoom(room);
})
.catch((err) => {
// ...
});
};
render() {
// ...
return (
// ...
<button
className="compose"
// Make sure that you add ".bind(this)" so that we
// can access the props from "createRoom":
onClick={this.createRoom.bind(this)}
></button>
// ...
)
}
}
const mapDispatchToProps = {
setRooms,
selectRoom,
addRoom, // Add this line!
};
// ...
Now, try to create yet another room with "bob". When the request is done, you should see the room appear in your menu almost instantly! Sweet!
You now know how to use AJAX requests to call the **Phoenix API **and dynamically add data to your client. In the next chapter, we'll tackle the final and most important piece of our app's functionality -- real-time messaging!