Phoenix Todos - Adding Lists and Tasks

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.

Written by Pete Corey on Oct 19, 2016.

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.