Phoenix Todos - Updating and Deleting

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 26, 2016.

Update List Name

The next piece of functionality we need to knock out is the ability to rename lists.

To do this, we’ll create a helper method on our List model called update_name. This method simply changes the name of the given List:


Repo.get(PhoenixTodos.List, id)
|> changeset(%{
  name: name
})
|> Repo.update!

We’ll create a new channel event, "update_name", to handle name change requests, and we’ll wire up a Redux thunk to push a "update_name" event onto our channel:


channel.push("update_name", { list_id, name })
  .receive("ok", (list) => {
    dispatch(updateNameSuccess());
  })
  .receive("error", () => dispatch(updateNameFailure()))
  .receive("timeout", () => dispatch(updateNameFailure()));

After wiring up the rest of the necessary Redux plumbing, we’re able to update the names of our lists.

web/channels/list_channel.ex

... + def handle_in("update_name", %{ + "list_id" => list_id, + "name" => name + }, socket) do + list = List.update_name(list_id, name) + |> Repo.preload(:todos) + + broadcast! socket, "update_list", list + + {:noreply, socket} + end + end

web/models/list.ex

... + def update_name(id, name) do + Repo.get(PhoenixTodos.List, id) + |> changeset(%{ + name: name + }) + |> Repo.update! + end + def set_checked_status(todo_id, checked) do

web/static/js/actions/index.js

... +export const UPDATE_NAME_REQUEST = "UPDATE_NAME_REQUEST"; +export const UPDATE_NAME_SUCCESS = "UPDATE_NAME_SUCCESS"; +export const UPDATE_NAME_FAILURE = "UPDATE_NAME_FAILURE"; + export function signUpRequest() { ... } + +export function updateNameRequest() { + return { type: UPDATE_NAME_REQUEST }; +} + +export function updateNameSuccess() { + return { type: UPDATE_NAME_SUCCESS }; +} + +export function updateNameFailure() { + return { type: UPDATE_NAME_FAILURE }; +} + +export function updateName(list_id, name) { + return (dispatch, getState) => { + const { channel } = getState(); + dispatch(updateNameRequest()); + channel.push("update_name", { list_id, name }) + .receive("ok", (list) => { + dispatch(updateNameSuccess()); + }) + .receive("error", () => dispatch(updateNameFailure())) + .receive("timeout", () => dispatch(updateNameFailure())); + } +}

web/static/js/components/ListHeader.jsx

... this.setState({ editing: false }); - updateName.call({ - listId: this.props.list._id, - newName: this.refs.listNameInput.value, - }, alert); + this.props.updateName(this.props.list.id, this.refs.listNameInput.value); }

web/static/js/pages/ListPage.jsx

... addTask, - setCheckedStatus + setCheckedStatus, + updateName } from "../actions"; ... <div className="page lists-show"> - <ListHeader list={list} addTask={this.props.addTask}/> + <ListHeader list={list} addTask={this.props.addTask} updateName={this.props.updateName}/> <div className="content-scrollable list-items"> ... return dispatch(setCheckedStatus(todo_id, status)); + }, + updateName: (list_id, name) => { + return dispatch(updateName(list_id, name)); }

Delete Lists

Let’s give users the ability to delete lists in our application.

We’ll start by creating a delete function in our List model. delete simply deletes the specified model object:


Repo.get(PhoenixTodos.List, id)
|> Repo.delete!

We’ll call List.delete from a "delete_list" channel event handler. Once deleted, we’ll also broadcast a "remove_list" event down to all connectd clients:


list = List.delete(list_id)
|> Repo.preload(:todos)

broadcast! socket, "remove_list", list

We’ll trigger this "delete_list" event with a Redux thunk:


channel.push("delete_list", { list_id, name })
  .receive("ok", (list) => {
    dispatch(deleteListSuccess());
  })
  .receive("error", () => dispatch(deleteListFailure()))
  .receive("timeout", () => dispatch(deleteListFailure()));

Lastly, we need to handle the new "remove_list" event that will be broadcast to all connected clients. We’ll set up a "remove_list" event listener on the client, and trigger a REMOVE_LIST action from the listener:


channel.on("remove_list", list => {
  dispatch(removeList(list));
});

The REMOVE_LISTENER action simply filters the specified list out of our application’s set of lists:


lists = state.lists.filter(list => {
  return list.id !== action.list.id
});

After combining all of that with some Redux plumbing, users can delete lists.

web/channels/list_channel.ex

... + def handle_in("delete_list", %{ + "list_id" => list_id, + }, socket) do + list = List.delete(list_id) + |> Repo.preload(:todos) + + broadcast! socket, "remove_list", list + + {:noreply, socket} + end + end

web/models/list.ex

... + def delete(id) do + Repo.get(PhoenixTodos.List, id) + |> Repo.delete! + end + def set_checked_status(todo_id, checked) do

web/static/js/actions/index.js

... export const UPDATE_LIST = "UPDATE_LIST"; +export const REMOVE_LIST = "REMOVE_LIST"; ... +export const DELETE_LIST_REQUEST = "DELETE_LIST_REQUEST"; +export const DELETE_LIST_SUCCESS = "DELETE_LIST_SUCCESS"; +export const DELETE_LIST_FAILURE = "DELETE_LIST_FAILURE"; + export function signUpRequest() { ... +export function removeList(list) { + return { type: REMOVE_LIST, list }; +} + export function connectSocket(jwt) { ... }) + channel.on("remove_list", list => { + dispatch(removeList(list)); + }); }; ... } + +export function deleteListRequest() { + return { type: DELETE_LIST_REQUEST }; +} + +export function deleteListSuccess() { + return { type: DELETE_LIST_SUCCESS }; +} + +export function deleteListFailure() { + return { type: DELETE_LIST_FAILURE }; +} + +export function deleteList(list_id, name) { + return (dispatch, getState) => { + const { channel } = getState(); + dispatch(deleteListRequest()); + channel.push("delete_list", { list_id, name }) + .receive("ok", (list) => { + dispatch(deleteListSuccess()); + }) + .receive("error", () => dispatch(deleteListFailure())) + .receive("timeout", () => dispatch(deleteListFailure())); + } +}

web/static/js/components/ListHeader.jsx

... if (confirm(message)) { // eslint-disable-line no-alert - remove.call({ listId: list._id }, alert); - /* this.context.router.push('/');*/ + this.props.deleteList(list.id); + this.context.router.push('/'); }

web/static/js/pages/ListPage.jsx

... setCheckedStatus, - updateName + updateName, + deleteList } from "../actions"; ... <div className="page lists-show"> - <ListHeader list={list} addTask={this.props.addTask} updateName={this.props.updateName}/> + <ListHeader list={list} + addTask={this.props.addTask} + updateName={this.props.updateName} + deleteList={this.props.deleteList}/> <div className="content-scrollable list-items"> ... return dispatch(updateName(list_id, name)); + }, + deleteList: (list_id) => { + return dispatch(deleteList(list_id)); }

web/static/js/reducers/index.js

... UPDATE_LIST, + REMOVE_LIST, JOIN_LISTS_CHANNEL_SUCCESS, ... return Object.assign({}, state, { lists }); + case REMOVE_LIST: + lists = state.lists.filter(list => { + return list.id !== action.list.id + }); + return Object.assign({}, state, { lists }); case CONNECT_SOCKET:

Delete Todos

Next, we’ll give users the ability to delete completed todos from their lists.

We’ll start by creating a delete_todo helper in our List model. This method deletes the specified Todo:


todo = Repo.get(PhoenixTodos.Todo, todo_id)
|> Repo.preload(:list)

Repo.delete!(todo)

It’s also interesting to note that the delete_todo helper returns the parent list of the task:


todo.list

We use this returned list in our "delete_todo" channel event handler to broadcast an "udpate_list" event to all connected clients:


list = List.delete_todo(todo_id)
|> Repo.preload(:todos)

broadcast! socket, "update_list", list

We’ll kick off this "delete_todo" event with a Redux thunk called deleteTodo:


channel.push("delete_todo", { todo_id, name })
  .receive("ok", (list) => {
    dispatch(deleteTodoSuccess());
  })
  .receive("error", () => dispatch(deleteTodoFailure()))
  .receive("timeout", () => dispatch(deleteTodoFailure()));

And with a little more Redux plumbing, users can remove completed todo items.

web/channels/list_channel.ex

... + def handle_in("delete_todo", %{ + "todo_id" => todo_id, + }, socket) do + list = List.delete_todo(todo_id) + |> Repo.preload(:todos) + + broadcast! socket, "update_list", list + + {:noreply, socket} + end + end

web/models/list.ex

... + def delete_todo(todo_id) do + todo = Repo.get(PhoenixTodos.Todo, todo_id) + |> Repo.preload(:list) + + Repo.delete!(todo) + + todo.list + end + def set_checked_status(todo_id, checked) do

web/static/js/actions/index.js

... +export const DELETE_TODO_REQUEST = "DELETE_TODO_REQUEST"; +export const DELETE_TODO_SUCCESS = "DELETE_TODO_SUCCESS"; +export const DELETE_TODO_FAILURE = "DELETE_TODO_FAILURE"; + export function signUpRequest() { ... } + +export function deleteTodoRequest() { + return { type: DELETE_TODO_REQUEST }; +} + +export function deleteTodoSuccess() { + return { type: DELETE_TODO_SUCCESS }; +} + +export function deleteTodoFailure() { + return { type: DELETE_TODO_FAILURE }; +} + +export function deleteTodo(todo_id, name) { + return (dispatch, getState) => { + const { channel } = getState(); + dispatch(deleteTodoRequest()); + channel.push("delete_todo", { todo_id, name }) + .receive("ok", (list) => { + dispatch(deleteTodoSuccess()); + }) + .receive("error", () => dispatch(deleteTodoFailure())) + .receive("timeout", () => dispatch(deleteTodoFailure())); + } +}

web/static/js/components/TodoItem.jsx

... deleteTodo() { - remove.call({ todoId: this.props.todo.id }, alert); + this.props.deleteTodo(this.props.todo.id); }

web/static/js/pages/ListPage.jsx

... updateName, - deleteList + deleteList, + deleteTodo } from "../actions"; ... setCheckedStatus={this.props.setCheckedStatus} + deleteTodo={this.props.deleteTodo} /> ... return dispatch(deleteList(list_id)); + }, + deleteTodo: (todo_id) => { + return dispatch(deleteTodo(todo_id)); }

Final Thoughts

These changes wrap up all of the list and task CRUD functionality in our application. Again, it’s interesting to notice that the vast majority of the work required to implement these features lives in the front-end of the application.

Next week, we’ll work on introducing the concept of private lists into our application. Stay tuned!

A Five Minute Introduction to NoSQL Injection

Written by Pete Corey on Oct 24, 2016.

NoSQL injection is one of the most common vulnerabilities I find in Meteor applications.

I’m even starting to notice NoSQL injection vulnerabilities in other stacks and frameworks as well, like vanilla Node.js applications, and even Phoenix/Elixir applications.

I’ve written a short one page, five minute introduction to NoSQL injection for applications using MongoDB. My goal is to give you a quick primer on what the vulnerability looks like and how to fix it. That way, you’ll be able to identify it and seal up the vulnerability in your own applications.

I’m giving out a PDF version of the introduction to everyone who signs up for my newsletter. To sign up, enter your first name and email address below and hit subscribe!

Along with the NoSQL injection primer, you’ll receive weekly updates on all East5th articles and projects. Each newsletter also has a small selection of links and resources that I’ve found helpful when building secure, robust, maintainable software.

After you’ve signed up and read the NoSQL introduction, respond to the email and let me know if there’s any way I can help you build better software!

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.