Phoenix Todos - Public and Private Lists

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 Nov 16, 2016.

Make Private

Now that our channel connection can be authenticated, we can gives users the ability to make their lists private.

To start, we’ll add a "make_private" channel event handler. This handler will call List.make_private and set the list’s user_id equal to the socket’s currently authenticated user:


list = get_user_id(socket)
|> List.make_private(list_id)
|> Repo.preload(:todos)

Once we’ve done that, we’ll broadcast a "update_list" event to all connected clients.

However, if a list becomes private, we’ll want to remove it from other users’ clients, instead of just showing the change. To do this, we’ll have to intercept all outbound "update_list" events:


intercept ["update_list"]

def handle_out("update_list", list, socket) do

If a user has permission to see the outgoing list, we’ll push another "update_list" event. Otherwise, we’ll push a "remove_list" event:


case List.canView?(get_user_id(socket), list) do
  true ->
    push(socket, "update_list", list)
  false ->
    push(socket, "remove_list", list)
end

After wiring up all of the necessary Redux plumbing to call our "make_private" event, the functionality it complete.

web/channels/list_channel.ex

... + intercept ["update_list"] + defp get_user_id(socket) do ... + def handle_in("make_private", %{ + "list_id" => list_id, + }, socket) do + list = get_user_id(socket) + |> List.make_private(list_id) + |> Repo.preload(:todos) + + broadcast! socket, "update_list", list + + {:noreply, socket} + end + def handle_in("delete_todo", %{ ... + def handle_out("update_list", list, socket) do + case List.canView?(get_user_id(socket), list) do + true -> + push(socket, "update_list", list) + false -> + push(socket, "remove_list", list) + end + {:noreply, socket} + end + end

web/models/list.ex

... @required_fields ~w(name incomplete_count) - @optional_fields ~w() + @optional_fields ~w(user_id) ... + def make_private(user_id, id) do + Repo.get(PhoenixTodos.List, id) + |> changeset(%{ + user_id: user_id + }) + |> Repo.update! + end + def delete_todo(todo_id) do ... + def canView?(_, %{user_id: nil}), do: true + def canView?(user_id, %{user_id: user_id}), do: true + def canView?(_, _), do: false + end

web/static/js/actions/index.js

... +export const MAKE_PRIVATE_REQUEST = "MAKE_PRIVATE_REQUEST"; +export const MAKE_PRIVATE_SUCCESS = "MAKE_PRIVATE_SUCCESS"; +export const MAKE_PRIVATE_FAILURE = "MAKE_PRIVATE_FAILURE"; + export const DELETE_TODO_REQUEST = "DELETE_TODO_REQUEST"; ... channel.on("update_list", list => { + console.log("update_list", list) dispatch(updateList(list)); ... +export function makePrivateRequest() { + return { type: MAKE_PRIVATE_REQUEST }; +} + +export function makePrivateSuccess() { + return { type: MAKE_PRIVATE_SUCCESS }; +} + +export function makePrivateFailure() { + return { type: MAKE_PRIVATE_FAILURE }; +} + +export function makePrivate(list_id) { + return (dispatch, getState) => { + const { channel } = getState(); + dispatch(makePrivateRequest()); + channel.push("make_private", { list_id }) + .receive("ok", (list) => { + dispatch(makePrivateSuccess()); + }) + .receive("error", () => dispatch(makePrivateFailure())) + .receive("timeout", () => dispatch(makePrivateFailure())); + } +} + export function deleteTodoRequest() {

web/static/js/components/ListHeader.jsx

... } else { - makePrivate.call({ listId: list._id }, alert); + this.props.makePrivate(list.id); }

web/static/js/pages/ListPage.jsx

... deleteList, + makePrivate, deleteTodo ... updateName={this.props.updateName} - deleteList={this.props.deleteList}/> + deleteList={this.props.deleteList} + makePrivate={this.props.makePrivate} + /> <div className="content-scrollable list-items"> ... }, + makePrivate: (list_id) => { + return dispatch(makePrivate(list_id)); + }, deleteTodo: (todo_id) => {

Make Public

Just as we let users make their lists private, we need to let them make their private lists public again.

We’ll do this by adding a "make_public" channel event that sets the user_id field on the specified list to nil and broadcasts an "update_list" event.


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

broadcast! socket, "update_list", list

Unfortunately, this introduces a situation where lists are added back into the UI through a "update_list" event rather than a "add_list" event.

To handle this, we need to check if the "UPDATE_LIST" Redux reducer actually found the list it was trying to update. If it didn’t, we’ll push the list to the end of the list, adding it to the UI:


if (!found) {
  lists.push(action.list);
}

And with that, users can make their private lists public.

web/channels/list_channel.ex

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

web/models/list.ex

... + def make_public(id) do + Repo.get(PhoenixTodos.List, id) + |> changeset(%{ + user_id: nil + }) + |> Repo.update! + end + def delete_todo(todo_id) do

web/static/js/actions/index.js

... +export const MAKE_PUBLIC_REQUEST = "MAKE_PUBLIC_REQUEST"; +export const MAKE_PUBLIC_SUCCESS = "MAKE_PUBLIC_SUCCESS"; +export const MAKE_PUBLIC_FAILURE = "MAKE_PUBLIC_FAILURE"; + export const DELETE_TODO_REQUEST = "DELETE_TODO_REQUEST"; ... channel.on("update_list", list => { - console.log("update_list", list) dispatch(updateList(list)); ... +export function makePublicRequest() { + return { type: MAKE_PUBLIC_REQUEST }; +} + +export function makePublicSuccess() { + return { type: MAKE_PUBLIC_SUCCESS }; +} + +export function makePublicFailure() { + return { type: MAKE_PUBLIC_FAILURE }; +} + +export function makePublic(list_id) { + return (dispatch, getState) => { + const { channel } = getState(); + dispatch(makePublicRequest()); + channel.push("make_public", { list_id }) + .receive("ok", (list) => { + dispatch(makePublicSuccess()); + }) + .receive("error", () => dispatch(makePublicFailure())) + .receive("timeout", () => dispatch(makePublicFailure())); + } +} + export function deleteTodoRequest() {

web/static/js/components/ListHeader.jsx

... if (list.user_id) { - makePublic.call({ listId: list._id }, alert); + this.props.makePublic(list.id); } else {

web/static/js/pages/ListPage.jsx

... makePrivate, + makePublic, deleteTodo ... makePrivate={this.props.makePrivate} + makePublic={this.props.makePublic} /> ... }, + makePublic: (list_id) => { + return dispatch(makePublic(list_id)); + }, deleteTodo: (todo_id) => {

web/static/js/reducers/index.js

... case UPDATE_LIST: + let found = false; let lists = state.lists.map(list => { - return list.id === action.list.id ? action.list : list; + if (list.id === action.list.id) { + found = true; + return action.list; + } + else { + return list; + } }); + if (!found) { + lists.push(action.list); + } return Object.assign({}, state, { lists });

Final Thoughts

At this point, we’ve roughly recreated all of the features of the Meteor Todos application in Phoenix and Elixir.

I’ll be the first to admit that there are many problems with the project as it currently stands. My solution to channel authentication isn’t the best, many channel events aren’t making proper authorization checks, the front-end Redux architecture is awful, etc… That being said, this was a fantastic learning experience.

Building out Meteor-esque functionality in Phoenix is definitely more work than using Meteor, but I still believe that the benefits of using an Elixir backend outweigh the drawbacks. With a little more effort, I think I’ll be able to reduce the upfront burden quite a bit through packages and libraries.

Expect many upcoming articles discussing what I’ve learned from this conversion and how to approach building Elixir and Phoenix applications from the perspective of a Meteor developer.