Phoenix Todos - Public 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 Oct 5, 2016.

List and Todo Models

We’re getting to the point where we’ll be wanting real data in our application. To use real data, we’ll need to define the schemas and models that describe the data.

Looking at our original Lists schema, we know that each list needs a name, an incompleteCount (which we’ll call incomplete_count), and an optional reference to a user.

We can use Phoenix’s phoenix.gen.model generator to create this model for us:


mix phoenix.gen.model List lists name:string \
                                 incomplete_count:integer \
                                 user_id:references:users

Running this command creates a migration to create the "users" table in our database. It also creates our PhoenixTodos.List model.

We can repeat this process for our Todos collection. Looking at the Todos schema, we know we’ll need a text field, a checked field, a reference to its parent list, and a timestamp.

Once again, we can use the phoenix.gen.model generator to create this model and migration for us:


mix phoenix.gen.model Todo todos text:string \
                                 checked:boolean \
                                 list_id:references:lists

Notice that we left the timestamp out of our generator call. Phoenix adds timestamp fields for us automatically.

Nearly all of the code generated for us is perfect. We only need to make one small tweak to our PhoenixTodos.List model. In addition to specifying that it belongs_to the PhoenixTodos.User model, we need to specify that each PhoenixTodos.List model has_many PhoenixTodos.Todo children:


has_many :todos, PhoenixTodos.Todo

Specifying this relationship on the parent List as well as the child Todo model will be very helpful down the line.

priv/repo/migrations/20160920202201_create_list.exs

+defmodule PhoenixTodos.Repo.Migrations.CreateList do + use Ecto.Migration + + def change do + create table(:lists) do + add :name, :string + add :incomplete_count, :integer + add :user_id, references(:users, on_delete: :delete_all) + + timestamps + end + create index(:lists, [:user_id]) + + end +end

priv/repo/migrations/20160920202208_create_todo.exs

+defmodule PhoenixTodos.Repo.Migrations.CreateTodo do + use Ecto.Migration + + def change do + create table(:todos) do + add :text, :string + add :checked, :boolean, default: false + add :list_id, references(:lists, on_delete: :delete_all) + + timestamps + end + create index(:todos, [:list_id]) + + end +end

test/models/list_test.exs

+defmodule PhoenixTodos.ListTest do + use PhoenixTodos.ModelCase + + alias PhoenixTodos.List + + @valid_attrs %{incomplete_count: 42, name: "some content"} + @invalid_attrs %{} + + test "changeset with valid attributes" do + changeset = List.changeset(%List{}, @valid_attrs) + assert changeset.valid? + end + + test "changeset with invalid attributes" do + changeset = List.changeset(%List{}, @invalid_attrs) + refute changeset.valid? + end +end

test/models/todo_test.exs

+defmodule PhoenixTodos.TodoTest do + use PhoenixTodos.ModelCase + + alias PhoenixTodos.Todo + + @valid_attrs %{checked: true, text: "some content"} + @invalid_attrs %{} + + test "changeset with valid attributes" do + changeset = Todo.changeset(%Todo{}, @valid_attrs) + assert changeset.valid? + end + + test "changeset with invalid attributes" do + changeset = Todo.changeset(%Todo{}, @invalid_attrs) + refute changeset.valid? + end +end

web/models/list.ex

+defmodule PhoenixTodos.List do + use PhoenixTodos.Web, :model + + schema "lists" do + field :name, :string + field :incomplete_count, :integer + belongs_to :user, PhoenixTodos.User + has_many :todos, PhoenixTodos.Todo + + timestamps + end + + @required_fields ~w(name incomplete_count) + @optional_fields ~w() + + @doc """ + Creates a changeset based on the `model` and `params`. + + If no params are provided, an invalid changeset is returned + with no validation performed. + """ + def changeset(model, params \\ :empty) do + model + |> cast(params, @required_fields, @optional_fields) + end +end

web/models/todo.ex

+defmodule PhoenixTodos.Todo do + use PhoenixTodos.Web, :model + + schema "todos" do + field :text, :string + field :checked, :boolean, default: false + belongs_to :list, PhoenixTodos.List + + timestamps + end + + @required_fields ~w(text checked) + @optional_fields ~w() + + @doc """ + Creates a changeset based on the `model` and `params`. + + If no params are provided, an invalid changeset is returned + with no validation performed. + """ + def changeset(model, params \\ :empty) do + model + |> cast(params, @required_fields, @optional_fields) + end +end

Seeding Data

Now that we’ve defined our schemas and models, we need to seed our database with data.

But before we do anything, we need to make sure that our migrations are up to date:


mix ecto.migrate

This will create our "lists" and "todos" tables our PostgreSQL database.

Now we can start writing our seeding script. We’ll model this script after the original fixtures.js file in our Meteor application.

We’ll start by creating a list of in-memory lists and todos that we’ll use to build our database objects:


[
  %{
    name: "Meteor Principles",
    items: [
      "Data on the Wire",
      "One Language",
      "Database Everywhere",
      "Latency Compensation",
      "Full Stack Reactivity",
      "Embrace the Ecosystem",
      "Simplicity Equals Productivity",
    ]
  },
  ...
]

Notice that we’re using double quote strings here instead of single quote strings, like the original Meteor appliaction. This is because single quote strings have a special meaning in Elixir.

Next, we’ll Enum.map over each object in this list. Each object represents a List, so we’ll build a List model object and insert it into our database:


list = Repo.insert!(%List{
  name: data.name,
  incomplete_count: length(data.items)
})

Each string in list.items represents a single Todo. We’ll map over this list build a new Todo model object, associating it with the List we just created using Ecto.build_assoc, and inserting it into the database:


Ecto.build_assoc(list, :todos, text: item)
|> Repo.insert!

Now we can run our seed script with the following command:


mix run priv/repo/seeds.exs

Or we can wipe our database and re-run our migrations and seed script with the following command:


mix ecto.reset

After running either of these, our database should have three lists, each with a set of associated todos.

priv/repo/seeds.exs

# mix run priv/repo/seeds.exs -# -# Inside the script, you can read and write to any of your -# repositories directly: -# -# PhoenixTodos.Repo.insert!(%PhoenixTodos.SomeModel{}) -# -# We recommend using the bang functions (`insert!`, `update!` -# and so on) as they will fail if something goes wrong. + +alias PhoenixTodos.{Repo, List} + +[ + %{ + name: "Meteor Principles", + items: [ + "Data on the Wire", + "One Language", + "Database Everywhere", + "Latency Compensation", + "Full Stack Reactivity", + "Embrace the Ecosystem", + "Simplicity Equals Productivity", + ] + }, + %{ + name: "Languages", + items: [ + "Lisp", + "C", + "C++", + "Python", + "Ruby", + "JavaScript", + "Scala", + "Erlang", + "6502 Assembly", + ] + }, + %{ + name: "Favorite Scientists", + items: [ + "Ada Lovelace", + "Grace Hopper", + "Marie Curie", + "Carl Friedrich Gauss", + "Nikola Tesla", + "Claude Shannon", + ] + } +] +|> Enum.map(fn data -> + list = Repo.insert!(%List{ + name: data.name, + incomplete_count: length(data.items) + }) + Enum.map(data.items, fn item -> + Ecto.build_assoc(list, :todos, text: item) + |> Repo.insert! + end) +end)

Public Lists

Now that our database is populated with Lists and Todos, we’re in a position where we can start passing this data down the the client.

To keep things as similar to our original Meteor application as possible, we’ll be doing all of our commuication via WebSockets. Specifically, we’ll be using Phoenix Channels.

We’ll start by creating a "lists.public" channel. This channel will emulate the "lists.public" publication in our Meteor application:


channel "lists.public", PhoenixTodos.ListChannel

When a client joins this channel, we’ll send them all public lists:


lists = List |> List.public |> Repo.all
{:ok, lists, socket}

Where public lists are lists without an associated User:


def public(query) do
  from list in query,
  where: is_nil(list.user_id)
end

In order to send these lists down the wire, we need to use Poison to tell Phoenix how to serialize our List objects into JSON:


@derive {Poison.Encoder, only: [
  :id,
  :name,
  :incomplete_count,
  :user_id
]}

Now our client can connect to our server and join the "lists.public" channel:


socket.connect();
socket.channel("lists.public", {})
  .join()

For each of the lists we receive back, well fire an ADD_LIST Redux action. The resolver for this action simply pushes the List object onto our application’s lists array:


return Object.assign({}, state, {
  lists: [...state.lists, action.list]
});

And with that (and a few minor bug fixes), our application is now showing lists pulled from the server!

test/models/list_test.exs

... alias PhoenixTodos.List + alias PhoenixTodos.User + alias PhoenixTodos.Repo ... end + + test "public" do + user = User.changeset(%User{}, %{ + email: "user@example.com", + password: "password" + }) |> Repo.insert! + public = Repo.insert!(%List{ + name: "public", + incomplete_count: 1 + }) + Repo.insert!(%List{ + name: "private", + incomplete_count: 1, + user_id: user.id + }) + + lists = List |> List.public |> Repo.all + + assert lists == [public] + end end

web/channels/list_channel.ex

+defmodule PhoenixTodos.ListChannel do + use Phoenix.Channel + alias PhoenixTodos.{Repo, List} + + def join("lists.public", _message, socket) do + lists = List |> List.public |> Repo.all + {:ok, lists, socket} + end + +end

web/channels/user_socket.ex

... # channel "rooms:*", PhoenixTodos.RoomChannel + channel "lists.public", PhoenixTodos.ListChannel

web/models/list.ex

... + @derive {Poison.Encoder, only: [ + :id, + :name, + :incomplete_count, + :user_id + ]} + schema "lists" do ... end + + def public(query) do + from list in query, + where: is_nil(list.user_id) + end + end

web/static/js/actions/index.js

... +export const ADD_LIST = "ADD_LIST"; + export function signUpRequest() { ... +export function addList(list) { + return { type: ADD_LIST, list }; +} + export function signUp(email, password, password_confirm) {

web/static/js/app.js

... import thunkMiddleware from "redux-thunk"; +import { + addList +} from "./actions"; +import socket from "./socket"; ... store.subscribe(render); + +socket.connect(); +socket.channel("lists.public", {}) + .join() + .receive("ok", (res) => { + res.forEach((list) => { + store.dispatch(addList(list)); + }); + }) + .receive("error", (res) => { + console.log("error", res); + });

web/static/js/components/ListList.jsx

... <Link - to={`/lists/${ list._id }`{:.language-javascript}} - key={list._id} + to={`/lists/${ list.id }`{:.language-javascript}} + key={list.id} title={list.name}

web/static/js/layouts/App.jsx

... // redirect / to a list once lists are ready - if (!loading && !children) { - const list = Lists.findOne(); - this.context.router.replace(`/lists/${ list._id }`{:.language-javascript}); + if (!loading && !children && this.props.lists.length) { + const list = this.props.lists[0]; + this.context.router.replace(`/lists/${ list.id }`{:.language-javascript}); } ... const publicList = Lists.findOne({ userId: { $exists: false } }); - this.context.router.push(`/lists/${ publicList._id }`{:.language-javascript}); + this.context.router.push(`/lists/${ publicList.id }`{:.language-javascript}); }

web/static/js/reducers/index.js

... SIGN_IN_FAILURE, + ADD_LIST, } from "../actions"; ... }); + case ADD_LIST: + return Object.assign({}, state, { + lists: [...state.lists, action.list] + }); default:

web/static/js/socket.js

... -// Now that you are connected, you can join channels with a topic: -let channel = socket.channel("topic:subtopic", {}) -channel.join() - .receive("ok", resp => { console.log("Joined successfully", resp) }) - .receive("error", resp => { console.log("Unable to join", resp) }) - export default socket

Accounts is Everything Meteor Does Right

Written by Pete Corey on Oct 3, 2016.

When I first came to Meteor, I was immediately amazed by full-stack reactivity. After the novelty of being able to instantly see changes in my database on the client wore off, I started building applications.

Soon, I realized that Meteor had something special.

Meteor’s Special Sauce

A big component of Meteor’s “special sauce” is the Accounts system. The powerful functionality and ease of use provided by the Accounts packages is a perfect example of what makes Meteor amazing.

Does your application need to authenticate users? No problem, just add accounts-password, and include a pre-built template in your application:


{{> loginButtons}}

While the default user interface might not win any design awards, the utility of the Accounts package goes above and beyond anything I’ve ever used in any other platform or framework.

Two simple steps take you from zero to sixty in terms of authentication. Once you’re cruising, you can add a variety of other packages to customize the user experience or to incorporate things like authorization, presence tracking, user management, etc…

Being Opinionated

All of this is possible because Meteor takes an incredibly opinionated view of users and accounts. The Accounts package dictates with complete authority everything from how and where user documents will be stored, all the way through to the how the client will pass credentials up to the server.

All of this is decided and implemented in advance, and for the most part, set in stone. The Accounts package offers a few inroads for customization, but for the most part everything is fixed.

In nearly every scenario I’ve faced in the Real World™ this has been fine. I’ve rarely needed to go above and beyond the functionality provided by the Accounts package.

In the few instances where I did need more than the Accounts package provided, I simply forked the package and customized it to suite my needs.

Phoenix In Comparison

Compare the process of setting up authentication in a Meteor application to the process of setting up authentication in our Phoenix Todos application.

In the literate commits series where we’re converting the Meteor Todos application over the to an Elixir/Phoenix stack, it took four articles to fully set up our authentication system:

Phoenix Todos - The User Model
Phoenix Todos - Back-end Authentication
Phoenix Todos - Transition to Redux
Phoenix Todos - Finishing Authentication

During those four weeks, we manually set up every aspect of our authentication system from designing our user model, setting up our JWT signing procedure, writing back-end routes to handle authentication actions, through to wiring up the front-end to handle calling these routes and persisting the authenticated user client-side.

This is a huge amount of work!

That’s not to say that implementing these things yourself doesn’t come without its benefits. Complete control gives you complete freedom. However, exercising these freedoms can be exhausting.

Final Thoughts

It’s important to remember that this kind of up-front work isn’t exclusive to using Phoenix. This has been my experience with every framework and platform I’ve used to date until I found Meteor.

Some frameworks try to ease some of the burden off of the developer, but none have managed to make my life as pleasant as Meteor.

Once I found Meteor, I immediately became accustomed to authentication being handled for me. Going back to manually implementing things that I want to “just work” feels like going back in time.

I’ve noticed that others feel the same way. When this developer talked about his experiences migrating away from Meteor he noted how he particularly missed Meteor’s robust accounts system.

Based on recent rumblings in the community, Meteor has fallen on hard times. In times like these, it’s important to remember everything the framework does well and balance those with its weaknesses.

Phoenix Todos - Finishing Authentication

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 Sep 28, 2016.

Client-side Validation Bug

You may have noticed that with our previous solution, only server-side errors would show on the sign-up form. Client-side validation was taking place in our onSubmit handler, but errors were never propagating to the UI!

This was happening because we were storing client-side validation errors in the JoinPage component’s state:


this.setState({ errors });

However, our render function was pulling errors out of the props passed into the component by Redux.

Our component didn’t have a single source of truth for the errors array.

The fix to this issue is fairly elegant. We can pull the validation checks out of the onSubmit handler and move them into our signUp action. If we detect any validation issues, we’ll return them to the JoinPage component by dispatching a SIGN_UP_FAILURE action:


if (errors.length) {
  return dispatch(signUpFailure(errors));
}

From our component’s perspective, all errors are seen as server-side errors and render correctly.

web/static/js/actions/index.js

... dispatch(signUpRequest()); + + let errors = []; + if (!email) { + errors.push({ email: "Email required" }); + } + if (!password) { + errors.push({ password: "Password required" }); + } + if (password_confirm !== password) { + errors.push({ password_confirm: "Please confirm your password" }); + } + if (errors.length) { + return Promise.resolve(dispatch(signUpFailure(errors))); + } + return fetch("/api/users", {

web/static/js/pages/AuthPageJoin.jsx

... const password_confirm = this.refs.password_confirm.value; - const errors = {}; - - if (!email) { - errors.email = 'Email required'; - } - if (!password) { - errors.password = 'Password required'; - } - if (password_confirm !== password) { - errors.password_confirm = 'Please confirm your password'; - } - - this.setState({ errors }); - if (Object.keys(errors).length) { - return; - }

Sign-out Actions

Now that we’ve established the pattern our Redux actions and reducers will follow, we can start implementing our other authentication features.

To give users the ability to sign out, we’ll start by creating three new actions: SIGN_OUT_REQUEST, SIGN_OUT_SUCCESS, and SIGN_OUT_FAILURE.

Along with the action creators for each of these actions, we’ll also create an asynchronous action function called signOut which accepts the current user’s JWT as an argument. This function makes a DELETE request to our /api/sessions endpoint, sending the jwt in the "Authorization" header:


return fetch("/api/sessions", {
  method: "delete",
  headers: {
    "Accept": "application/json",
    "Content-Type": "application/json",
    "Authorization": jwt
  }
})

Our SIGN_OUT_SUCCESS reducer clears the user and jwt fields in our application state:


case SIGN_OUT_SUCCESS:
  return Object.assign({}, state, {
    user: undefined,
    jwt: undefined
  });

And the SIGN_OUT_FAILURE resolver will save any errors from the server into errors.

Now that our sign-out actions and resolvers are set, we can wire our App component up to our Redux store with a call to connect, and replace our old Meteor.logout() code with a call to our signOut thunk:


this.props.signOut(this.props.jwt)

With that, authenticated users have the ability to sign out of our application!

web/static/js/actions/index.js

... +export const SIGN_OUT_REQUEST = "SIGN_OUT_REQUEST"; +export const SIGN_OUT_SUCCESS = "SIGN_OUT_SUCCESS"; +export const SIGN_OUT_FAILURE = "SIGN_OUT_FAILURE"; + export function signUpRequest() { ... +export function signOutRequest() { + return { type: SIGN_OUT_REQUEST }; +} + +export function signOutSuccess() { + return { type: SIGN_OUT_SUCCESS }; +} + +export function signOutFailure(errors) { + return { type: SIGN_OUT_FAILURE, errors }; +} + export function signUp(email, password, password_confirm) { ... } + +export function signOut(jwt) { + return (dispatch) => { + dispatch(signOutRequest()); + return fetch("/api/sessions", { + method: "delete", + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + "Authorization": jwt + } + }) + .then((res) => res.json()) + .then((res) => { + if (res.errors) { + dispatch(signOutFailure(res.errors)); + return false; + } + else { + dispatch(signOutSuccess()); + return true; + } + }); + } +}

web/static/js/layouts/App.jsx

... import Loading from '../components/Loading.jsx'; +import { connect } from "react-redux"; +import { signOut } from "../actions"; ... -export default class App extends React.Component { +class App extends React.Component { constructor(props) { ... logout() { - Meteor.logout(); - - // if we are on a private list, we'll need to go to a public one - if (this.props.params.id) { - const list = Lists.findOne(this.props.params.id); - if (list.userId) { - const publicList = Lists.findOne({ userId: { $exists: false } }); - this.context.router.push(`/lists/${ publicList._id }`{:.language-javascript}); - } - } + this.props.signOut(this.props.jwt) + .then((success) => { + if (success) { + // if we are on a private list, we'll need to go to a public one + if (this.props.params.id) { + const list = Lists.findOne(this.props.params.id); + if (list.userId) { + const publicList = Lists.findOne({ userId: { $exists: false } }); + this.context.router.push(`/lists/${ publicList._id }`{:.language-javascript}); + } + } + } + }); } ... }; + +export default connect( + (state) => state, + (dispatch) => ({ + signOut: (jwt) => { + return dispatch(signOut(jwt)); + } + }) +)(App);

web/static/js/reducers/index.js

... SIGN_UP_FAILURE, + SIGN_OUT_REQUEST, + SIGN_OUT_SUCCESS, + SIGN_OUT_FAILURE, } from "../actions"; ... }); + + case SIGN_OUT_REQUEST: + return state; + case SIGN_OUT_SUCCESS: + return Object.assign({}, state, { + user: undefined, + jwt: undefined + }); + case SIGN_OUT_FAILURE: + return Object.assign({}, state, { + errors: action.errors + }); default:

Persisting Users

Unfortunately, if a user refreshes the page after signing up, they’ll lose their authenticated status. This means a user would have to sign-in every time they load the application.

This issue is caused by the fact that we’re saving the user and jwt objects exclusively in our in-memory application state. When we reload the page, that state is reset.

Thankfully, we can fix this issue fairly quickly.

In our signUp thunk, once we recieve a successful response from the server, we can store the user and jwt objects into local storage.


localStorage.setItem("user", JSON.stringify(res.user));
localStorage.setItem("jwt", res.jwt);

Similarly, when a user signs out we’ll clear these local storage entries:


localStorage.removeItem("user");
localStorage.removeItem("jwt");

Now we can popoulate our initialState with these user and jwt values, if they exist in local storage:


const user = localStorage.getItem("user");
const jwt = localStorage.getItem("jwt");

const initialState = {
  user: user ? JSON.parse(user) : user,
  jwt,
  ...

And now when a authenticated user refreshes the page, they’ll stay authenticated.

web/static/js/actions/index.js

... else { + localStorage.setItem("user", JSON.stringify(res.user)); + localStorage.setItem("jwt", res.jwt); dispatch(signUpSuccess(res.user, res.jwt)); ... else { + localStorage.removeItem("user"); + localStorage.removeItem("jwt"); dispatch(signOutSuccess());

web/static/js/reducers/index.js

... +const user = localStorage.getItem("user"); +const jwt = localStorage.getItem("jwt"); + const initialState = { - user: undefined, - jwt: undefined, + user: user ? JSON.parse(user) : user, + jwt, loading: false,

Sign In Front-end

Finally, we can continue the same pattern we’ve been following and implement our sign-in functionality.

We’ll start by copying over the SignInPage component from our Meteor application. Next, we’ll make three new actions: SIGN_IN_REQUEST, SIGN_IN_SUCCESS, and SIGN_IN_FAILURE.

In addition to our actions, we’ll make an asynchronous action creator that sends a POST request to /api/sessions to initiate a sign-in.

The reducers for our new actions will be identical to our sign-up reducers, so we’ll save some typing and re-use them:


case SIGN_IN_SUCCESS:
case SIGN_UP_SUCCESS:
  return Object.assign({}, state, {
    user: action.user,
    jwt: action.jwt
  });
...

Lastly, we can replace the call to Meteor.loginWithPassword with a call to our signIn helper. If this call is successful, we’ll redirect to /:


this.state.signIn(email, password)
  .then((success) => {
    if (success) {
      this.context.router.push('/');
    }
  });

Otherwise, we’ll render any errors we find in this.props.errors:


const errors = (this.props.errors || []).reduce((errors, error) => {
  return Object.assign(errors, error);
}, {});

And with those changes, a user can now sign up, log out, and sign into our application!

web/static/js/actions/index.js

... +export const SIGN_IN_REQUEST = "SIGN_IN_REQUEST"; +export const SIGN_IN_SUCCESS = "SIGN_IN_SUCCESS"; +export const SIGN_IN_FAILURE = "SIGN_IN_FAILURE"; + export function signUpRequest() { ... +export function signInRequest() { + return { type: SIGN_IN_REQUEST }; +} + +export function signInSuccess() { + return { type: SIGN_IN_SUCCESS }; +} + +export function signInFailure(errors) { + return { type: SIGN_IN_FAILURE, errors }; +} + export function signUp(email, password, password_confirm) { ... } + +export function signIn(email, password) { + return (dispatch) => { + dispatch(signInRequest()); + + let errors = []; + if (!email) { + errors.push({ email: "Email required" }); + } + if (!password) { + errors.push({ password: "Password required" }); + } + if (errors.length) { + return Promise.resolve(dispatch(signInFailure(errors))); + } + + return fetch("/api/sessions", { + method: "post", + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ email, password }) + }) + .then((res) => res.json()) + .then((res) => { + if (res.errors) { + dispatch(signInFailure(res.errors)); + return false; + } + else { + localStorage.setItem("user", JSON.stringify(res.user)); + localStorage.setItem("jwt", res.jwt); + dispatch(signInSuccess(res.user, res.jwt)); + return true; + } + }); + } +}

web/static/js/pages/AuthPageSignIn.jsx

+import React from 'react'; +import AuthPage from './AuthPage.jsx'; +import { Link } from 'react-router'; +import { connect } from "react-redux"; +import { signIn } from "../actions"; + +class SignInPage extends React.Component { + constructor(props) { + super(props); + this.state = { + signIn: props.signIn + }; + this.onSubmit = this.onSubmit.bind(this); + } + + onSubmit(event) { + event.preventDefault(); + const email = this.refs.email.value; + const password = this.refs.password.value; + + this.state.signIn(email, password) + .then((success) => { + if (success) { + this.context.router.push('/'); + } + }); + } + + render() { + const errors = (this.props.errors || []).reduce((errors, error) => { + return Object.assign(errors, error); + }, {}); + const errorMessages = Object.keys(errors).map(key => errors[key]); + const errorClass = key => errors[key] && 'error'; + + const content = ( +
+

Sign In.

+

Signing in allows you to view private lists

+ <form onSubmit={this.onSubmit}> +
+ {errorMessages.map(msg => ( + <div className="list-item" key={msg}>{msg}
+ ))} +
+ <div className={`input-symbol ${errorClass('email')}`{:.language-javascript}}> + + + </div> + <div className={`input-symbol ${errorClass('password')}`{:.language-javascript}}> + + + </div> + + </form> + </div> + ); + + const link = Need an account? Join Now.</Link>; + + return <AuthPage content={content} link={link}/>; + } +} + +SignInPage.contextTypes = { + router: React.PropTypes.object, +}; + +export default connect( + (state) => { + return { + errors: state.errors + } + }, + (dispatch) => { + return { + signIn: (email, password) => { + return dispatch(signIn(email, password)); + } + }; + } +)(SignInPage);

web/static/js/reducers/index.js

... SIGN_OUT_FAILURE, + SIGN_IN_REQUEST, + SIGN_IN_SUCCESS, + SIGN_IN_FAILURE, } from "../actions"; ... switch (action.type) { + case SIGN_IN_REQUEST: case SIGN_UP_REQUEST: return state; + case SIGN_IN_SUCCESS: case SIGN_UP_SUCCESS: ... }); + case SIGN_IN_FAILURE: case SIGN_UP_FAILURE:

web/static/js/routes.jsx

... import AppContainer from './containers/AppContainer.jsx'; +import AuthPageSignIn from './pages/AuthPageSignIn.jsx'; import AuthPageJoin from './pages/AuthPageJoin.jsx'; ... <Route path="/" component={AppContainer}> + <Route path="signin" component={AuthPageSignIn}/> <Route path="join" component={AuthPageJoin}/>

Final Thoughts

Now that we’re getting more comfortable with React, it’s becoming more and more enjoyable to use.

The concept of a single application state, while undeniably weird at first, really simplifies a lot of complexities that can show up in more complicated applications. For example, having a single, canonical errors array that holds any error messages that might currently exist is amazing!

Coming from Blaze, the incredibly explicit data flow in a Redux-style application is comforting. It’s completely clear where each action is initiated and how it effects the application’s state.

Gone are the days of racking your brain trying to conceptualize a tree of reactive updates that brought your application into its current state.

Now that the authentication piece is finished (finally), next week we’ll move onto implementing the meat of our application!