This post is written as a set of Literate Commits. The goal of this style is to show you how this program came together from beginning to end. Each commit in the project is represented by a section of the article. Click each section’s header to see the commit on Github, or check out the repository and follow along.
Creating Public Lists
Now that we’re loading and displaying all public lists, we’ll want to be able to create new lists.
To emulate our Meteor application, new lists will be given unique names,
starting with "List A"
. If "List A"
already exists, we’ll use "List B"
, and so on.
We’ll implement this functionality in a create
function on our List
model. When create
is called with no arguments, we’ll default to using
"List"
as the base name, and "A"
as the suffix:
def create, do: create("List", "A")
When create
is called with two arguments (name
, and suffix
), we’ll
find any lists that exist with the given name:
PhoenixTodos.List
|> findByName("#{name} #{suffix}")
|> Repo.all
If we find one or more lists with that name, we’ll increment our
suffix
to the next character and try again:
def handle_create_find(_, name, suffix) do
[char] = to_char_list suffix
create(name, to_string [char + 1])
end
If we don’t find a list with that name, we know that our name
/suffix
combination is unique, so
we’ll insert the list.
We’ll call our create
function whenever our "lists.public"
channel
receives a "create_list"
message:
def handle_in("create_list", _, socket) do
list = List.create
Once the list is added, we’ll want to broadcast this change to all connected clients, so they can add the new list to their UI:
broadcast! socket, "add_list", list
Finally, we’ll reply with the newly created list.
On the client, we removed all references to insert.call
in favor of a
newly created createList
thunk. createList
pushes
the "create_list"
message up to the server, and handles all responses.
web/channels/list_channel.ex
... + def handle_in("create_list", _, socket) do + list = List.create + |> Repo.preload(:todos) + + broadcast! socket, "add_list", list + + {:reply, {:ok, list}, socket} + end + end
web/models/list.ex
... + alias PhoenixTodos.Repo + @derive {Poison.Encoder, only: [ ... + def create(name, suffix) do + PhoenixTodos.List + |> findByName("#{name} #{suffix}") + |> Repo.all + |> handle_create_find(name, suffix) + end + def create, do: create("List", "A") + + def handle_create_find([], name, suffix) do + changeset(%PhoenixTodos.List{}, %{ + name: "#{name} #{suffix}", + incomplete_count: 0 + }) + |> Repo.insert! + end + + def handle_create_find(_, name, suffix) do + [char] = to_char_list suffix + create(name, to_string [char + 1]) + end + def public(query) do ... + def findByName(query, name) do + from list in query, + where: list.name == ^name + end + end
web/static/js/actions/index.js
... +export const CREATE_LIST_REQUEST = "CREATE_LIST_REQUEST"; +export const CREATE_LIST_SUCCESS = "CREATE_LIST_SUCCESS"; +export const CREATE_LIST_FAILURE = "CREATE_LIST_FAILURE"; + export function signUpRequest() { ... -export function joinListsChannel(channel) { +export function joinListsChannel(channelName) { return (dispatch, getState) => { ... - socket - .channel(channel) + let channel = socket.channel(channelName); + channel .join() ... dispatch(joinListsChannelSuccess(channel)); + dispatch(createAddListListeners(channel)); }) ... } + +export function createListRequest() { + return { type: CREATE_LIST_REQUEST }; +} + +export function createListSuccess() { + return { type: CREATE_LIST_SUCCESS }; +} + +export function createListFailure() { + return { type: CREATE_LIST_FAILURE }; +} + +export function createList(router) { + return (dispatch, getState) => { + const { channel } = getState(); + dispatch(createListRequest()); + channel.push("create_list") + .receive("ok", (list) => { + dispatch(createListSuccess()); + router.push(`/lists/${ list.id }`); + }) + .receive("error", () => dispatch(createListFailure())) + .receive("timeout", () => dispatch(createListFailure())); + } +} + +export function createAddListListeners(channel) { + return (dispatch, getState) => { + channel.on("add_list", list => { + dispatch(addList(list)); + }); + }; +}
web/static/js/components/ListList.jsx
... const { router } = this.context; - // const listId = insert.call((err) => { - // if (err) { - // router.push('/'); - // /* eslint-disable no-alert */ - // alert('Could not create list.'); - // } - // }); - // router.push(`/lists/${ listId }`); + this.props.createList(router); }
web/static/js/layouts/App.jsx
... import { connect } from "react-redux"; -import { signOut } from "../actions"; +import { signOut, createList } from "../actions"; ... <UserMenu user={user} logout={this.logout}/> - <ListList lists={lists}/> + <ListList lists={lists} createList={this.props.createList}/> </section> ... return dispatch(signOut(jwt)); + }, + createList: (router) => { + return dispatch(createList(router)); }
web/static/js/reducers/index.js
... ADD_LIST, + JOIN_LISTS_CHANNEL_SUCCESS, + CREATE_LIST_SUCCESS, } from "../actions"; ... socket: undefined, + channel: undefined, user: user ? JSON.parse(user) : user, ... return Object.assign({}, state, { socket: action.socket }); + case JOIN_LISTS_CHANNEL_SUCCESS: + return Object.assign({}, state, { channel: action.channel }); default:
List Ordering
Our last commit had a small issue. When lists were added, they appeared at the end of the list, as expected. However, when we reloaded the application, lists would appear in a seemingly random order.
To fix this, we need to order the lists by when they were inserted into the database.
A quick way to do this is to add an order_by
clause to our
List.public
query:
order_by: list.inserted_at,
Now our lists will be consistently ordered, even through refreshes.
web/models/list.ex
... where: is_nil(list.user_id), + order_by: list.inserted_at, preload: [:todos]
Adding Tasks
Now that we can add lists, we should be able to add tasks to our lists.
We’ll start by adding an add_task
function to our List
module.
add_task
takes in the list’s id
that we’re updating, and the text
of the new task.
After we grab our list
from the database, we can use
Ecto.build_assoc
to create a new Task
associated with it:
Ecto.build_assoc(list, :todos, text: text)
|> Repo.insert!
Next, we’ll need to increment the list’s incomplete_count
:
list
|> PhoenixTodos.List.changeset(%{
incomplete_count: list.incomplete_count + 1
})
|> Repo.update!
Now we’ll wire our new add_task
model function up to a "add_task"
message handler on our ListChannel
:
def handle_in("add_task", %{
"list_id" => list_id,
"text" => text
}, socket) do
list = List.add_task(list_id, text)
Once we’ve added the list, we need to inform all subscribed clients that
the list has been updated. We’ll do this by broadcasting a
"update_list"
message:
broadcast! socket, "update_list", list
Finally, we can replace our call to insert.call
with a Redux thunk
that triggers our "add_task"
channel message:
this.props.addTask(this.props.list.id, input.value);
Now we can add new tasks to each of our todos!
web/channels/list_channel.ex
... + def handle_in("add_task", %{ + "list_id" => list_id, + "text" => text + }, socket) do + list = List.add_task(list_id, text) + |> Repo.preload(:todos) + + broadcast! socket, "update_list", list + + {:noreply, socket} + end + end
web/models/list.ex
... + def add_task(id, text) do + list = Repo.get(PhoenixTodos.List, id) + + Ecto.build_assoc(list, :todos, text: text) + |> Repo.insert! + + list + |> PhoenixTodos.List.changeset(%{ + incomplete_count: list.incomplete_count + 1 + }) + |> Repo.update! + end + def public(query) do
web/static/js/actions/index.js
... export const ADD_LIST = "ADD_LIST"; +export const UPDATE_LIST = "UPDATE_LIST"; ... +export const ADD_TASK_REQUEST = "ADD_TASK_REQUEST"; +export const ADD_TASK_SUCCESS = "ADD_TASK_SUCCESS"; +export const ADD_TASK_FAILURE = "ADD_TASK_FAILURE"; + export function signUpRequest() { ... +export function updateList(list) { + return { type: UPDATE_LIST, list }; +} + export function connectSocket(jwt) { ... +export function addTaskRequest() { + return { type: ADD_TASK_REQUEST }; +} + +export function addTaskSuccess() { + return { type: ADD_TASK_SUCCESS }; +} + +export function addTaskFailure() { + return { type: ADD_TASK_FAILURE }; +} + +export function addTask(list_id, text) { + return (dispatch, getState) => { + const { channel } = getState(); + dispatch(addTaskRequest()); + channel.push("add_task", { list_id, text }) + .receive("ok", (list) => { + dispatch(addTaskSuccess()); + }) + .receive("error", () => dispatch(addTaskFailure())) + .receive("timeout", () => dispatch(addTaskFailure())); + } +} + ... + channel.on("update_list", list => { + dispatch(updateList(list)); + }) };
web/static/js/components/ListHeader.jsx
... if (input.value.trim()) { - insert.call({ - listId: this.props.list._id, - text: input.value, - }, alert); + this.props.addTask(this.props.list.id, input.value); input.value = '';
web/static/js/pages/ListPage.jsx
... import Message from '../components/Message.jsx'; +import { connect } from "react-redux"; +import { addTask } from "../actions"; -export default class ListPage extends React.Component { +class ListPage extends React.Component { constructor(props) { ... <div className="page lists-show"> - <ListHeader list={list}/> + <ListHeader list={list} addTask={this.props.addTask}/> <div className="content-scrollable list-items"> ... }; + + +export default connect( + (state) => state, + (dispatch) => ({ + addTask: (list_id, text) => { + return dispatch(addTask(list_id, text)); + } + }) +)(ListPage);
web/static/js/reducers/index.js
... ADD_LIST, + UPDATE_LIST, JOIN_LISTS_CHANNEL_SUCCESS, ... }); + case UPDATE_LIST: + let lists = state.lists.map(list => { + return list.id === action.list.id ? action.list : list; + }); + return Object.assign({}, state, { lists }); case CONNECT_SOCKET:
Checking Tasks
Next up on our feature list is giving users the ability to toggle tasks as completed or incomplete.
To do this, we’ll create a set_checked_status
helper method in our
List
model. Oddly, set_checked_status
takes in a todo_id
and a
checked
boolean. This will likely be a good place for a future refactor.
The set_checked_status
function starts by grabbing the specified todo
and it’s associated todo:
todo = Repo.get(PhoenixTodos.Todo, todo_id)
|> Repo.preload(:list)
list = todo.list
Next, it uses checked
to determine if we’ll be incrementing or
decrementing incomplete_count
on our list:
inc = if (checked), do: - 1, else: 1
We can update our todo by setting the checked
field:
todo
|> PhoenixTodos.Todo.changeset(%{
checked: checked
})
|> Repo.update!
And we can update our list by setting the incomplete_count
field:
list
|> PhoenixTodos.List.changeset(%{
incomplete_count: list.incomplete_count + inc
})
|> Repo.update!
Now that we have a functional helper method in our model, we can call it
whenever we receive a "set_checked_status"
message in our list
channel:
def handle_in("set_checked_status", %{
"todo_id" => todo_id,
"status" => status
}, socket) do
list = List.set_checked_status(todo_id, status)
Lastly, we’ll broadcast a "update_list"
message to all connected
clients so they can see this change in realtime.
Now we can replace our call to the old setCheckedStatus
Meteor method
with a call to an asynchronous action creator which pushes our
"set_checked_status"
message up to the server.
With that, our users can check and uncheck todos.
web/channels/list_channel.ex
... + def handle_in("set_checked_status", %{ + "todo_id" => todo_id, + "status" => status + }, socket) do + list = List.set_checked_status(todo_id, status) + |> Repo.preload(:todos) + + broadcast! socket, "update_list", list + + {:noreply, socket} + end + end
web/models/list.ex
... + def set_checked_status(todo_id, checked) do + todo = Repo.get(PhoenixTodos.Todo, todo_id) + |> Repo.preload(:list) + list = todo.list + inc = if (checked), do: - 1, else: 1 + + todo + |> PhoenixTodos.Todo.changeset(%{ + checked: checked + }) + |> Repo.update! + + list + |> PhoenixTodos.List.changeset(%{ + incomplete_count: list.incomplete_count + inc + }) + |> Repo.update! + end + def public(query) do
web/static/js/actions/index.js
... +export const SET_CHECKED_STATUS_REQUEST = "SET_CHECKED_STATUS_REQUEST"; +export const SET_CHECKED_STATUS_SUCCESS = "SET_CHECKED_STATUS_SUCCESS"; +export const SET_CHECKED_STATUS_FAILURE = "SET_CHECKED_STATUS_FAILURE"; + export function signUpRequest() { ... +export function setCheckedStatusRequest() { + return { type: SET_CHECKED_STATUS_REQUEST }; +} + +export function setCheckedStatusSuccess() { + return { type: SET_CHECKED_STATUS_SUCCESS }; +} + +export function setCheckedStatusFailure() { + return { type: SET_CHECKED_STATUS_FAILURE }; +} + + +export function setCheckedStatus(todo_id, status) { + return (dispatch, getState) => { + const { channel } = getState(); + dispatch(setCheckedStatusRequest()); + channel.push("set_checked_status", { todo_id, status }) + .receive("ok", (list) => { + dispatch(setCheckedStatusSuccess()); + }) + .receive("error", () => dispatch(setCheckedStatusFailure())) + .receive("timeout", () => dispatch(setCheckedStatusFailure())); + } +} + export function createAddListListeners(channel) {
web/static/js/components/TodoItem.jsx
... -/* import { - * setCheckedStatus, - * updateText, - * remove, - * } from '../../api/todos/methods.js';*/ - export default class TodoItem extends React.Component { ... setTodoCheckStatus(event) { - setCheckedStatus.call({ - todoId: this.props.todo.id, - newCheckedStatus: event.target.checked, - }); + this.props.setCheckedStatus(this.props.todo.id, event.target.checked); }
web/static/js/pages/ListPage.jsx
... import { connect } from "react-redux"; -import { addTask } from "../actions"; +import { + addTask, + setCheckedStatus +} from "../actions"; ... onEditingChange={this.onEditingChange} + setCheckedStatus={this.props.setCheckedStatus} /> ... return dispatch(addTask(list_id, text)); + }, + setCheckedStatus: (todo_id, status) => { + return dispatch(setCheckedStatus(todo_id, status)); }
Sorting Tasks
Just like our lists, our tasks are having a sorting problem. Checking and unchecking a task will randomize its position in the list.
There are two ways to solve this issue. We can either sort our todos
when we Preload
them on the server, or we can do our sorting on the
client. For variety, let’s go with the second option.
Let’s sort primarily based on the “created at” timestamp of each task.
To do this, we’ll need to serialize the inserted_at
timestamp for each
task we send to the client.
@derive {Poison.Encoder, only: [
...
:inserted_at
]}
We can then sort our todos
on this timestamp before rendering our
<TodoItem>
components:
.sort((a, b) => {
return new Date(a.inserted_at) - new Date(b.inserted_at);
})
We’ll also want to have a secondary sort on the task’s text to break and ties that may occur (especially in seed data):
.sort((a, b) => {
let diff = new Date(a.inserted_at) - new Date(b.inserted_at);
return diff == 0 ? a.text > b.text : diff;
})
With those changes, our tasks order themselves correctly in each list.
web/models/todo.ex
... :text, - :checked + :checked, + :inserted_at ]}
web/static/js/pages/ListPage.jsx
... } else { - Todos = todos.map(todo => ( + Todos = todos + .sort((a, b) => { + let diff = new Date(a.inserted_at) - new Date(b.inserted_at); + return diff == 0 ? a.text > b.text : diff; + }) + .map(todo => ( <TodoItem
Final Thoughts
As we implement more and more functionality, we’re falling into a pattern. Phoenix channel events can be used just like we’d use Meteor methods, and we can manually broadcast events that act like Meteor publication messages.
Most of the work of implementing these features is happening on the front end of the application. The Redux boilerplate required to implement any feature is significant and time consuming.
Next week we should finish up the rest of the list/task functionality and then we can turn our attention to handling private lists.